This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.
Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
`boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
filters to mainland VN via the existing country polygon, resolves the
GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
`nearestProvince()` heuristic — `lookup(lng,lat)` returns
province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
and falls back to the centroid heuristic only when polygons aren't
loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
IndustrialPark, ProjectDevelopment and Property using the new lookup.
Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
GIST index on `location`. New `TransportLine` for metro/highway
multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
resolves province/district codes from the boundary polygons, upserts
with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
GET /poi/by-bbox — GeoJSON for map overlays
GET /poi/nearby — sidebar "tiện ích xung quanh" (HMAC distance ranks)
GET /poi/coverage — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
project / KCN detail pages.
Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
(RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
Daily 02:00 → POI category rotation (1/day, 20-day cycle)
Mon 02:30 → admin-boundaries provinces
Wed 02:30 → admin-boundaries districts
Sat 02:30 → admin-boundaries wards
1st of month 03:00 → industrial-parks (per chunk)
All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
per-row "Sync now", recent runs list with auto-refresh every 15s.
Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import { Loader2, MapPin } from 'lucide-react';
|
|
import * as React from 'react';
|
|
import {
|
|
POI_ICONS,
|
|
POI_LABELS,
|
|
poiApi,
|
|
type NearbyPoiResult,
|
|
type PoiCategory,
|
|
} from '@/lib/poi-api';
|
|
|
|
interface Props {
|
|
/** Centre coordinates of the asset (listing / project / KCN). */
|
|
lat: number;
|
|
lng: number;
|
|
/** Search radius in metres. Default 1500m (~15 phút đi bộ). */
|
|
radius?: number;
|
|
/** Restrict to these categories. Default: 6 most relevant for residential. */
|
|
categories?: PoiCategory[];
|
|
/** N nearest POI shown per category. */
|
|
limitPerCategory?: number;
|
|
className?: string;
|
|
}
|
|
|
|
const DEFAULT_CATEGORIES: PoiCategory[] = [
|
|
'SCHOOL_PRIMARY',
|
|
'SCHOOL_SECONDARY',
|
|
'HOSPITAL',
|
|
'MARKET',
|
|
'BANK',
|
|
'METRO_STATION',
|
|
];
|
|
|
|
function formatDistance(m: number): string {
|
|
if (m < 1000) return `${m} m`;
|
|
return `${(m / 1000).toFixed(1)} km`;
|
|
}
|
|
|
|
/**
|
|
* Sidebar widget that lists the nearest POI of each category around a
|
|
* geo-tagged asset. Renders inside listing detail, project detail and KCN
|
|
* detail pages.
|
|
*/
|
|
export function NearbyPoiSidebar({
|
|
lat,
|
|
lng,
|
|
radius = 1500,
|
|
categories = DEFAULT_CATEGORIES,
|
|
limitPerCategory = 3,
|
|
className,
|
|
}: Props) {
|
|
const [data, setData] = React.useState<NearbyPoiResult | null>(null);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
poiApi
|
|
.nearby({ lat, lng, radius, categories, limitPerCategory })
|
|
.then((res) => {
|
|
if (cancelled) return;
|
|
setData(res);
|
|
})
|
|
.catch((err: Error) => {
|
|
if (cancelled) return;
|
|
setError(err.message ?? 'Không tải được tiện ích');
|
|
})
|
|
.finally(() => {
|
|
if (cancelled) return;
|
|
setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [lat, lng, radius, categories, limitPerCategory]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div
|
|
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
|
|
>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Đang tải tiện ích xung quanh…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className={`rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive ${className ?? ''}`}
|
|
>
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data || data.all.length === 0) {
|
|
return (
|
|
<div
|
|
className={`rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
|
|
>
|
|
Chưa có dữ liệu tiện ích trong bán kính {formatDistance(radius)}.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`rounded-lg border border-border bg-card ${className ?? ''}`}>
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
|
<h3 className="text-sm font-semibold text-foreground">Tiện ích xung quanh</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
{data.meta.totalCount} điểm · bán kính {formatDistance(data.meta.radiusMeters)}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col divide-y divide-border">
|
|
{categories.map((cat) => {
|
|
const items = data.byCategory[cat] ?? [];
|
|
if (items.length === 0) return null;
|
|
return (
|
|
<div key={cat} className="px-4 py-2.5">
|
|
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase text-muted-foreground">
|
|
<span aria-hidden>{POI_ICONS[cat]}</span>
|
|
{POI_LABELS[cat]}
|
|
</div>
|
|
<ul className="flex flex-col gap-1.5">
|
|
{items.map((p) => (
|
|
<li key={p.id} className="flex items-start gap-2 text-sm">
|
|
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-medium text-foreground">{p.name}</div>
|
|
{p.address && (
|
|
<div className="truncate text-xs text-muted-foreground">{p.address}</div>
|
|
)}
|
|
</div>
|
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
|
{formatDistance(p.distanceM)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|