fix(industrial): improve OSM review UX + public map visibility
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 9s
CI / AI Services (Python) — Smoke (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m7s
Deploy / Build API Image (push) Failing after 16s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 15s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m13s
Security Scanning / Trivy Scan — Web Image (push) Failing after 49s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 40s
Security Scanning / Trivy Filesystem Scan (push) Failing after 40s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Four UX issues surfaced when reviewing the new OSM-sync pipeline against
the actual 2,193 imports — fixed in this commit:

1. Admin queue surfaced noise first.
   `ListOsmPendingHandler` now sorts by `totalAreaHa DESC` (real KCN
   first, single-factory `landuse=industrial` polygons last) and accepts
   `minAreaHa` (default 50 ha) plus a `region` filter. The admin page
   exposes both as dropdowns — "Tất cả / ≥ 5 / ≥ 50 / ≥ 200 / ≥ 500 ha".
   Top-of-queue is now Bàu Bàng (2,597 ha) and Nhơn Trạch (2,535 ha).

2. Promote dialog said "KCN KCN Đại An" — duplicate prefix.
   Reworded to "Sắp promote: <name>" so the row name stands on its own.

3. Province was "Chưa xác định" on 2,107 of 2,193 OSM rows.
   The OSM tags lacked any addr:* hint, so the importer never had
   anything to write. Added `scripts/data/vn-province-centroids.ts` (63
   provinces with capital-city coords) and a `nearestProvince(lat, lng)`
   fallback in `parseFeature()`. Shipped a one-shot backfill script
   `scripts/backfill-osm-provinces.ts` and ran it — every existing OSM
   row now has a province (Hồ Chí Minh: 408, Lạng Sơn: 232,
   Quảng Ninh: 220, Hà Nội: 172, Hải Phòng: 105, …). Admin can correct
   on promote if the nearest-centroid heuristic picked the wrong
   neighbour for a long-thin province.

4. Public map looked empty — only 20 curated parks visible.
   Added an opt-in toggle "Hiển thị KCN OSM" with a small legend above
   the map. When on, the bbox endpoint returns OSM raw rows too; markers
   render in amber (vs. green for curated) at slightly smaller radius
   and lower opacity, so the visual hierarchy stays clear. Refetch is
   wired through a ref so the toggle takes effect without remounting
   the map.

Verified in browser preview: zoom-out shows clusters of 320 / 71 / etc.
across the country with the toggle on, and just three small clusters
(20 curated parks) when off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-30 00:09:24 +07:00
parent e7ca4fe8b1
commit c15bdcc6bf
11 changed files with 442 additions and 18 deletions

View File

@@ -50,6 +50,10 @@ export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery>
conditions.push(`province = $${p++}`);
values.push(q.province);
}
if (q.region) {
conditions.push(`region::text = $${p++}`);
values.push(q.region);
}
if (q.query) {
conditions.push(
`(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`,
@@ -57,6 +61,13 @@ export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery>
values.push(`%${q.query}%`);
p += 1;
}
if (q.minAreaHa > 0) {
// Use COALESCE so rows whose area we couldn't compute (NODE-only
// imports) only show up when the admin explicitly drops the floor
// to 0.
conditions.push(`COALESCE("totalAreaHa", 0) >= $${p++}`);
values.push(q.minAreaHa);
}
const where = conditions.join(' AND ');
const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
@@ -95,7 +106,7 @@ export class ListOsmPendingHandler implements IQueryHandler<ListOsmPendingQuery>
ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng
FROM "IndustrialPark"
WHERE ${where}
ORDER BY "lastSyncedAt" DESC NULLS LAST, "totalAreaHa" DESC NULLS LAST
ORDER BY "totalAreaHa" DESC NULLS LAST, "lastSyncedAt" DESC NULLS LAST
LIMIT $${p++} OFFSET $${p}`,
...values,
limit,

View File

@@ -1,6 +1,11 @@
/**
* Admin OSM review queue — list raw OSM-imported parks that haven't yet
* been promoted to the public catalogue.
*
* `minAreaHa` lets admins skip the long tail of `landuse=industrial`
* features OSM tags that turn out to be single factories or warehouses
* (typically < 5 ha). The default of 50 ha surfaces "real" KCN first; pass
* `0` to see everything.
*/
export class ListOsmPendingQuery {
constructor(
@@ -8,5 +13,7 @@ export class ListOsmPendingQuery {
public readonly limit: number = 50,
public readonly query?: string,
public readonly province?: string,
public readonly minAreaHa: number = 50,
public readonly region?: string,
) {}
}

View File

@@ -302,6 +302,8 @@ export class IndustrialParksController {
@Query('limit') limit?: string,
@Query('q') q?: string,
@Query('province') province?: string,
@Query('minAreaHa') minAreaHa?: string,
@Query('region') region?: string,
) {
return this.queryBus.execute(
new ListOsmPendingQuery(
@@ -309,6 +311,8 @@ export class IndustrialParksController {
limit ? parseInt(limit, 10) : 50,
q,
province,
minAreaHa !== undefined ? Number(minAreaHa) : 50,
region,
),
);
}

View File

@@ -35,8 +35,10 @@ import {
import { Link } from '@/i18n/navigation';
import {
industrialApi,
REGION_LABELS,
type OsmPendingItem,
type OsmPendingResult,
type VietnamRegion,
} from '@/lib/khu-cong-nghiep-api';
/**
@@ -56,6 +58,17 @@ import {
const PAGE_SIZE = 50;
/** Buckets for the "Diện tích tối thiểu" filter. 50 ha is the default
* because most "real" KCN start there — anything below tends to be a
* single factory or warehouse mistagged as `landuse=industrial`. */
const MIN_AREA_OPTIONS: { value: number; label: string }[] = [
{ value: 0, label: 'Tất cả' },
{ value: 5, label: '≥ 5 ha' },
{ value: 50, label: '≥ 50 ha (KCN nhỏ)' },
{ value: 200, label: '≥ 200 ha (KCN lớn)' },
{ value: 500, label: '≥ 500 ha (KCN trọng điểm)' },
];
const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [
{ key: 'name', label: 'Tên KCN' },
{ key: 'developer', label: 'Chủ đầu tư' },
@@ -91,6 +104,8 @@ export default function AdminOsmReviewPage() {
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [provinceFilter, setProvinceFilter] = useState('');
const [minAreaHa, setMinAreaHa] = useState<number>(50);
const [region, setRegion] = useState<VietnamRegion | ''>('');
// Promote dialog state
const [promoteTarget, setPromoteTarget] = useState<OsmPendingItem | null>(null);
@@ -107,6 +122,8 @@ export default function AdminOsmReviewPage() {
limit: PAGE_SIZE,
q: search || undefined,
province: provinceFilter || undefined,
minAreaHa,
region: region || undefined,
});
setResult(data);
} catch (e) {
@@ -114,7 +131,7 @@ export default function AdminOsmReviewPage() {
} finally {
setLoading(false);
}
}, [page, search, provinceFilter]);
}, [page, search, provinceFilter, minAreaHa, region]);
useEffect(() => {
fetchQueue();
@@ -218,6 +235,43 @@ export default function AdminOsmReviewPage() {
className="h-8 text-sm"
/>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-foreground-dim">
Diện tích tối thiểu
</label>
<select
value={minAreaHa}
onChange={(e) => {
setPage(1);
setMinAreaHa(Number(e.target.value));
}}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
>
{MIN_AREA_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="w-32">
<label className="mb-1 block text-xs text-foreground-dim">Vùng miền</label>
<select
value={region}
onChange={(e) => {
setPage(1);
setRegion((e.target.value as VietnamRegion) || '');
}}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Tất cả</option>
{(Object.keys(REGION_LABELS) as VietnamRegion[]).map((r) => (
<option key={r} value={r}>
{REGION_LABELS[r]}
</option>
))}
</select>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-foreground-dim">Tỉnh / TP</label>
<Input
@@ -234,7 +288,7 @@ export default function AdminOsmReviewPage() {
<Search className="mr-1.5 h-3.5 w-3.5" />
Tìm
</Button>
{(search || provinceFilter) && (
{(search || provinceFilter || region || minAreaHa !== 50) && (
<Button
type="button"
size="sm"
@@ -243,11 +297,13 @@ export default function AdminOsmReviewPage() {
setSearchInput('');
setSearch('');
setProvinceFilter('');
setRegion('');
setMinAreaHa(50);
setPage(1);
}}
>
<X className="mr-1 h-3 w-3" />
Xóa bộ lọc
Đt lại
</Button>
)}
</form>
@@ -444,8 +500,9 @@ export default function AdminOsmReviewPage() {
<span className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
KCN <strong>{promoteTarget?.name}</strong> sẽ đưc chuyển sang trạng thái public
(OSM_PROMOTED). Chọn các trường muốn khóa đ bảo vệ chúng khỏi OSM sync sau này.
Sắp promote: <strong>{promoteTarget?.name}</strong>. KCN sẽ chuyển sang trạng thái
public (OSM_PROMOTED). Chọn các trường muốn khóa đ bảo vệ chúng khỏi OSM sync sau
này.
</span>
</span>
</DialogDescription>

View File

@@ -2,6 +2,7 @@
import { Factory, Map as MapIcon, List, Columns } from 'lucide-react';
import * as React from 'react';
import { OsmMapLegend } from '@/components/khu-cong-nghiep/osm-map-legend';
import { OsmParkBboxMap } from '@/components/khu-cong-nghiep/osm-park-bbox-map';
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
@@ -19,6 +20,10 @@ export default function KhuCongNghiepPage() {
limit: PAGE_SIZE,
});
const [viewMode, setViewMode] = React.useState<ViewMode>('split');
// When true, the bbox map also shows raw OSM-imported parks (amber
// markers) on top of the curated catalog. Off by default — most users
// want only the verified set.
const [includeOsmRaw, setIncludeOsmRaw] = React.useState(false);
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
@@ -113,7 +118,16 @@ export default function KhuCongNghiepPage() {
{/* Map-only view — bbox-driven, loads ALL parks in viewport */}
{viewMode === 'map' && (
<OsmParkBboxMap className="h-[calc(100vh-260px)]" />
<>
<OsmMapLegend
includeOsmRaw={includeOsmRaw}
onToggleOsmRaw={setIncludeOsmRaw}
/>
<OsmParkBboxMap
className="h-[calc(100vh-300px)]"
includeOsmRaw={includeOsmRaw}
/>
</>
)}
{/* Split view: list left, sticky bbox map right (lg+ only) */}
@@ -126,8 +140,16 @@ export default function KhuCongNghiepPage() {
))}
</div>
</div>
<div className="hidden lg:block">
<OsmParkBboxMap className="sticky top-20 h-[calc(100vh-220px)]" />
<div className="hidden lg:flex lg:flex-col lg:gap-2">
<OsmMapLegend
includeOsmRaw={includeOsmRaw}
onToggleOsmRaw={setIncludeOsmRaw}
compact
/>
<OsmParkBboxMap
className="sticky top-20 h-[calc(100vh-260px)]"
includeOsmRaw={includeOsmRaw}
/>
</div>
</div>
)}

View File

@@ -0,0 +1,67 @@
'use client';
import { Info } from 'lucide-react';
import * as React from 'react';
interface OsmMapLegendProps {
includeOsmRaw: boolean;
onToggleOsmRaw: (value: boolean) => void;
/** Smaller variant for the split-view sidebar. */
compact?: boolean;
}
/**
* Legend + toggle that sits above the bbox map. Explains the two marker
* colors (curated vs raw OSM) and lets the user opt into showing the
* un-reviewed OpenStreetMap imports.
*/
export function OsmMapLegend({
includeOsmRaw,
onToggleOsmRaw,
compact = false,
}: OsmMapLegendProps) {
return (
<div
className={`flex flex-wrap items-center gap-3 rounded-lg border bg-card ${
compact ? 'px-3 py-2 text-xs' : 'px-4 py-2.5 text-sm'
}`}
role="group"
aria-label="Chú giải bản đồ KCN"
>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-full border-2 border-white shadow"
style={{ backgroundColor: '#22c55e' }}
aria-hidden="true"
/>
<span className="text-foreground">KCN đã xác minh</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-full border-2 border-white shadow opacity-70"
style={{ backgroundColor: '#f59e0b' }}
aria-hidden="true"
/>
<span className="text-foreground">KCN từ OpenStreetMap (chưa duyệt)</span>
</div>
<label className="ml-auto flex cursor-pointer items-center gap-1.5 select-none">
<input
type="checkbox"
checked={includeOsmRaw}
onChange={(e) => onToggleOsmRaw(e.target.checked)}
className="rounded border-border"
/>
<span>Hiển thị KCN OSM</span>
</label>
{includeOsmRaw && !compact && (
<p className="flex w-full items-center gap-1.5 border-t border-border pt-2 text-xs text-muted-foreground">
<Info className="h-3 w-3 shrink-0" />
KCN màu vàng dữ liệu thô từ OpenStreetMap, chưa đưc kiểm duyệt thông tin thể chưa
chính xác hoặc thiếu.
</p>
)}
</div>
);
}

View File

@@ -50,8 +50,12 @@ export function OsmParkBboxMap({
// Capture the current includeOsmRaw value via a ref so the moveend
// handler always sees the latest without re-binding the listener.
const includeOsmRawRef = React.useRef(includeOsmRaw);
// Bumping this triggers a manual refetch when the toggle changes —
// the moveend handler alone doesn't fire on prop changes.
const refetchTokenRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => {
includeOsmRawRef.current = includeOsmRaw;
refetchTokenRef.current?.();
}, [includeOsmRaw]);
React.useEffect(() => {
@@ -166,6 +170,8 @@ export function OsmParkBboxMap({
});
// Individual park markers (centroid Points) when not clustered.
// Color is data-driven: green = curated (MANUAL / OSM_PROMOTED),
// amber = raw OSM imports awaiting admin review.
map.addLayer({
id: POINT_LAYER_ID,
type: 'circle',
@@ -176,10 +182,26 @@ export function OsmParkBboxMap({
['==', ['get', '_kind'], 'point'],
],
paint: {
'circle-color': '#22c55e',
'circle-radius': 6,
'circle-color': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
'#f59e0b', // amber for raw OSM
'#22c55e', // green for curated
],
'circle-radius': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
5,
6,
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1.5,
'circle-opacity': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
0.7,
1,
],
},
});
@@ -191,8 +213,18 @@ export function OsmParkBboxMap({
source: SOURCE_ID,
filter: ['==', ['get', '_kind'], 'polygon'],
paint: {
'fill-color': '#22c55e',
'fill-opacity': 0.18,
'fill-color': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
'#f59e0b',
'#22c55e',
],
'fill-opacity': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
0.1,
0.18,
],
},
});
map.addLayer({
@@ -201,9 +233,19 @@ export function OsmParkBboxMap({
source: SOURCE_ID,
filter: ['==', ['get', '_kind'], 'polygon'],
paint: {
'line-color': '#22c55e',
'line-color': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
'#f59e0b',
'#22c55e',
],
'line-width': 2,
'line-opacity': 0.6,
'line-opacity': [
'case',
['==', ['get', 'dataSource'], 'OSM'],
0.4,
0.6,
],
},
});
@@ -250,6 +292,11 @@ export function OsmParkBboxMap({
// Initial fetch + listen to viewport changes.
void fetchParks();
// Wire up the prop-change refetch (used when `includeOsmRaw` flips
// — the moveend listener alone doesn't fire on parent re-renders).
refetchTokenRef.current = () => {
void fetchParks();
};
});
map.on('moveend', () => {

View File

@@ -271,6 +271,9 @@ export interface ListOsmPendingParams {
limit?: number;
q?: string;
province?: string;
/** Diện tích tối thiểu (ha). Default backend = 50 để lọc bớt nhà máy lẻ. */
minAreaHa?: number;
region?: VietnamRegion;
}
// ─── Labels ─────────────────────────────────────────────