feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator
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>
This commit is contained in:
321
apps/web/app/[locale]/(admin)/admin/osm/page.tsx
Normal file
321
apps/web/app/[locale]/(admin)/admin/osm/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Layers,
|
||||
MapPin,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Train,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
osmSyncApi,
|
||||
type OsmCoverageSummary,
|
||||
type OsmSyncLayer,
|
||||
type OsmSyncRun,
|
||||
} from '@/lib/osm-sync-api';
|
||||
|
||||
const STATUS_STYLES: Record<OsmSyncRun['status'], string> = {
|
||||
RUNNING: 'bg-blue-100 text-blue-800 ring-blue-200',
|
||||
SUCCESS: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
|
||||
PARTIAL: 'bg-amber-100 text-amber-800 ring-amber-200',
|
||||
FAILED: 'bg-red-100 text-red-800 ring-red-200',
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<OsmSyncRun['status'], React.ReactNode> = {
|
||||
RUNNING: <RefreshCw className="h-3 w-3 animate-spin" />,
|
||||
SUCCESS: <CheckCircle className="h-3 w-3" />,
|
||||
PARTIAL: <AlertTriangle className="h-3 w-3" />,
|
||||
FAILED: <XCircle className="h-3 w-3" />,
|
||||
};
|
||||
|
||||
function formatRelative(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60_000) return 'vừa xong';
|
||||
if (diff < 3_600_000) return `${Math.round(diff / 60_000)} phút trước`;
|
||||
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} giờ trước`;
|
||||
return `${Math.round(diff / 86_400_000)} ngày trước`;
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string | null): string {
|
||||
const startMs = new Date(start).getTime();
|
||||
const endMs = end ? new Date(end).getTime() : Date.now();
|
||||
const sec = Math.round((endMs - startMs) / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export default function AdminOsmDashboardPage() {
|
||||
const [coverage, setCoverage] = useState<OsmCoverageSummary | null>(null);
|
||||
const [runs, setRuns] = useState<OsmSyncRun[]>([]);
|
||||
const [layers, setLayers] = useState<OsmSyncLayer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [triggering, setTriggering] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cov, rs, ls] = await Promise.all([
|
||||
osmSyncApi.coverage(),
|
||||
osmSyncApi.runs({ limit: 30 }),
|
||||
osmSyncApi.layers(),
|
||||
]);
|
||||
setCoverage(cov);
|
||||
setRuns(rs);
|
||||
setLayers(ls);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Lỗi tải dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const int = setInterval(refresh, 15_000); // poll while RUNNING runs visible
|
||||
return () => clearInterval(int);
|
||||
}, [refresh]);
|
||||
|
||||
const trigger = async (layer: string, category?: string) => {
|
||||
const key = `${layer}/${category ?? '-'}`;
|
||||
setTriggering(key);
|
||||
try {
|
||||
await osmSyncApi.trigger({ layer, category });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Trigger fail');
|
||||
} finally {
|
||||
setTriggering(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">OSM Sync Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Đồng bộ OpenStreetMap → Goodgo: ranh giới hành chính, POI, KCN, giao thông.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Làm mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top stats */}
|
||||
{coverage && (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
<StatCard
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
label="Đơn vị hành chính"
|
||||
value={coverage.totals.administrativeUnits.toLocaleString('vi-VN')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<MapPin className="h-4 w-4" />}
|
||||
label="POI tổng"
|
||||
value={coverage.totals.poiTotal.toLocaleString('vi-VN')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<MapPin className="h-4 w-4 text-green-600" />}
|
||||
label="KCN"
|
||||
value={coverage.totals.industrialParks.toLocaleString('vi-VN')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Train className="h-4 w-4" />}
|
||||
label="Bến/Ga"
|
||||
value={coverage.totals.transportStations.toLocaleString('vi-VN')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Train className="h-4 w-4" />}
|
||||
label="Tuyến giao thông"
|
||||
value={coverage.totals.transportLines.toLocaleString('vi-VN')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-border px-4 py-2.5">
|
||||
<h2 className="text-sm font-semibold">Coverage theo layer</h2>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Layer / Category</TableHead>
|
||||
<TableHead className="text-right">Tổng</TableHead>
|
||||
<TableHead className="text-right">Promoted</TableHead>
|
||||
<TableHead className="text-right">Raw</TableHead>
|
||||
<TableHead>Sync gần nhất</TableHead>
|
||||
<TableHead className="text-right">Hành động</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{coverage?.rows.map((r) => {
|
||||
const key = `${r.layer}/${r.category ?? '-'}`;
|
||||
const layerDef = layers.find(
|
||||
(l) => l.layer === r.layer && (l.category ?? null) === (r.category ?? null),
|
||||
);
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.layer}</div>
|
||||
{r.category && (
|
||||
<div className="text-xs text-muted-foreground">{r.category}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{r.total.toLocaleString('vi-VN')}
|
||||
{r.withGeometry !== undefined && r.withGeometry !== r.total && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({r.withGeometry} có geom)
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{r.promoted?.toLocaleString('vi-VN') ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{r.raw?.toLocaleString('vi-VN') ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatRelative(r.lastSyncedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{layerDef && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={triggering === key}
|
||||
onClick={() => trigger(r.layer, r.category ?? undefined)}
|
||||
>
|
||||
{triggering === key ? (
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Sync
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent runs */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-border px-4 py-2.5">
|
||||
<h2 className="text-sm font-semibold">Sync runs gần đây</h2>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Layer</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Added</TableHead>
|
||||
<TableHead className="text-right">Updated</TableHead>
|
||||
<TableHead className="text-right">Skipped</TableHead>
|
||||
<TableHead>Bắt đầu</TableHead>
|
||||
<TableHead>Thời gian</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
|
||||
Chưa có sync run nào.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
runs.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.layer}</div>
|
||||
{r.category && (
|
||||
<div className="text-xs text-muted-foreground">{r.category}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_STYLES[r.status]}`}
|
||||
>
|
||||
{STATUS_ICONS[r.status]}
|
||||
{r.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">{r.rowsAdded}</TableCell>
|
||||
<TableCell className="text-right font-mono">{r.rowsUpdated}</TableCell>
|
||||
<TableCell className="text-right font-mono">{r.rowsSkipped}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<Clock className="mr-1 inline h-3 w-3" />
|
||||
{formatRelative(r.startedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDuration(r.startedAt, r.finishedAt)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs uppercase text-muted-foreground">{label}</div>
|
||||
<div className="truncate text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Factory,
|
||||
Globe,
|
||||
LogOut,
|
||||
Menu,
|
||||
Sparkles,
|
||||
@@ -38,6 +39,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 },
|
||||
{ href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory },
|
||||
{ href: '/admin/industrial/osm-review' as const, label: 'Review OSM (KCN)', icon: Factory },
|
||||
{ href: '/admin/osm' as const, label: 'OSM Sync Dashboard', icon: Globe },
|
||||
{ href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user