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 },
|
||||
];
|
||||
|
||||
|
||||
151
apps/web/components/poi/nearby-poi-sidebar.tsx
Normal file
151
apps/web/components/poi/nearby-poi-sidebar.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
58
apps/web/lib/osm-sync-api.ts
Normal file
58
apps/web/lib/osm-sync-api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
export interface OsmCoverageRow {
|
||||
layer: string;
|
||||
category: string | null;
|
||||
total: number;
|
||||
withGeometry?: number;
|
||||
promoted?: number;
|
||||
raw?: number;
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OsmCoverageSummary {
|
||||
rows: OsmCoverageRow[];
|
||||
totals: {
|
||||
administrativeUnits: number;
|
||||
poiTotal: number;
|
||||
industrialParks: number;
|
||||
transportStations: number;
|
||||
transportLines: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OsmSyncRun {
|
||||
id: string;
|
||||
layer: string;
|
||||
category: string | null;
|
||||
chunk: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
status: 'RUNNING' | 'SUCCESS' | 'PARTIAL' | 'FAILED';
|
||||
rowsAdded: number;
|
||||
rowsUpdated: number;
|
||||
rowsSkipped: number;
|
||||
rowsLocked: number;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface OsmSyncLayer {
|
||||
layer: string;
|
||||
category?: string;
|
||||
weight: 'light' | 'medium' | 'heavy';
|
||||
}
|
||||
|
||||
export const osmSyncApi = {
|
||||
layers: () => apiClient.get<OsmSyncLayer[]>('/admin/osm/layers'),
|
||||
coverage: () => apiClient.get<OsmCoverageSummary>('/admin/osm/coverage'),
|
||||
runs: (params: { layer?: string; status?: string; limit?: number } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== '') q.append(k, String(v));
|
||||
});
|
||||
const qs = q.toString();
|
||||
return apiClient.get<OsmSyncRun[]>(`/admin/osm/runs${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
trigger: (body: { layer: string; category?: string; chunk?: string }) =>
|
||||
apiClient.post<{ runId: string; status: string }>('/admin/osm/runs', body),
|
||||
};
|
||||
140
apps/web/lib/poi-api.ts
Normal file
140
apps/web/lib/poi-api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type PoiCategory =
|
||||
| 'SCHOOL_PRIMARY' | 'SCHOOL_SECONDARY' | 'UNIVERSITY'
|
||||
| 'HOSPITAL' | 'CLINIC' | 'PHARMACY'
|
||||
| 'MARKET' | 'SUPERMARKET' | 'MALL' | 'CONVENIENCE'
|
||||
| 'BANK' | 'ATM'
|
||||
| 'PARK'
|
||||
| 'GAS_STATION' | 'POLICE' | 'POST_OFFICE'
|
||||
| 'METRO_STATION' | 'RAILWAY_STATION' | 'BUS_STATION' | 'AIRPORT';
|
||||
|
||||
/** Vietnamese display labels for each POI category. */
|
||||
export const POI_LABELS: Record<PoiCategory, string> = {
|
||||
SCHOOL_PRIMARY: 'Trường tiểu học',
|
||||
SCHOOL_SECONDARY: 'Trường THCS / THPT',
|
||||
UNIVERSITY: 'Đại học / Cao đẳng',
|
||||
HOSPITAL: 'Bệnh viện',
|
||||
CLINIC: 'Phòng khám',
|
||||
PHARMACY: 'Nhà thuốc',
|
||||
MARKET: 'Chợ',
|
||||
SUPERMARKET: 'Siêu thị',
|
||||
MALL: 'TTTM',
|
||||
CONVENIENCE: 'Cửa hàng tiện lợi',
|
||||
BANK: 'Ngân hàng',
|
||||
ATM: 'ATM',
|
||||
PARK: 'Công viên',
|
||||
GAS_STATION: 'Cây xăng',
|
||||
POLICE: 'Công an',
|
||||
POST_OFFICE: 'Bưu điện',
|
||||
METRO_STATION: 'Ga Metro',
|
||||
RAILWAY_STATION: 'Ga tàu',
|
||||
BUS_STATION: 'Bến xe',
|
||||
AIRPORT: 'Sân bay',
|
||||
};
|
||||
|
||||
/** Single-emoji icon for chips / map markers (no extra image dep needed). */
|
||||
export const POI_ICONS: Record<PoiCategory, string> = {
|
||||
SCHOOL_PRIMARY: '🏫', SCHOOL_SECONDARY: '🎒', UNIVERSITY: '🎓',
|
||||
HOSPITAL: '🏥', CLINIC: '⚕️', PHARMACY: '💊',
|
||||
MARKET: '🛒', SUPERMARKET: '🏪', MALL: '🛍️', CONVENIENCE: '🏬',
|
||||
BANK: '🏦', ATM: '🏧',
|
||||
PARK: '🌳',
|
||||
GAS_STATION: '⛽', POLICE: '👮', POST_OFFICE: '📮',
|
||||
METRO_STATION: '🚇', RAILWAY_STATION: '🚉', BUS_STATION: '🚌', AIRPORT: '✈️',
|
||||
};
|
||||
|
||||
/** Tailwind colour class per category — keep marker coding consistent. */
|
||||
export const POI_COLORS: Record<PoiCategory, string> = {
|
||||
SCHOOL_PRIMARY: '#3b82f6', SCHOOL_SECONDARY: '#2563eb', UNIVERSITY: '#1d4ed8',
|
||||
HOSPITAL: '#ef4444', CLINIC: '#f87171', PHARMACY: '#fb7185',
|
||||
MARKET: '#f59e0b', SUPERMARKET: '#fbbf24', MALL: '#fcd34d', CONVENIENCE: '#fde68a',
|
||||
BANK: '#8b5cf6', ATM: '#a78bfa',
|
||||
PARK: '#22c55e',
|
||||
GAS_STATION: '#64748b', POLICE: '#0f172a', POST_OFFICE: '#be185d',
|
||||
METRO_STATION: '#0ea5e9', RAILWAY_STATION: '#0284c7', BUS_STATION: '#0369a1', AIRPORT: '#075985',
|
||||
};
|
||||
|
||||
export interface NearbyPoi {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PoiCategory;
|
||||
distanceM: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
export interface NearbyPoiResult {
|
||||
byCategory: Partial<Record<PoiCategory, NearbyPoi[]>>;
|
||||
all: NearbyPoi[];
|
||||
meta: { radiusMeters: number; totalCount: number; requestedCategories: PoiCategory[] | null };
|
||||
}
|
||||
|
||||
export interface PoiBboxFeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PoiCategory;
|
||||
provinceCode: string | null;
|
||||
districtCode: string | null;
|
||||
};
|
||||
}[];
|
||||
meta: { count: number; truncated: boolean; categories: PoiCategory[] };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* API */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const poiApi = {
|
||||
/**
|
||||
* Fetch nearest N POI (per category) within `radius` metres of the given
|
||||
* point. Drives the "tiện ích xung quanh" sidebar.
|
||||
*/
|
||||
nearby: (params: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number;
|
||||
categories?: PoiCategory[];
|
||||
limitPerCategory?: number;
|
||||
}): Promise<NearbyPoiResult> => {
|
||||
const q = new URLSearchParams({
|
||||
lat: String(params.lat),
|
||||
lng: String(params.lng),
|
||||
radius: String(params.radius),
|
||||
});
|
||||
if (params.categories?.length) q.set('categories', params.categories.join(','));
|
||||
if (params.limitPerCategory) q.set('limitPerCategory', String(params.limitPerCategory));
|
||||
return apiClient.get<NearbyPoiResult>(`/poi/nearby?${q.toString()}`);
|
||||
},
|
||||
|
||||
/** GeoJSON for map overlays. Used by the listing detail mini-map and KCN page. */
|
||||
byBbox: (params: {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
categories?: PoiCategory[];
|
||||
limit?: number;
|
||||
}): Promise<PoiBboxFeatureCollection> => {
|
||||
const q = new URLSearchParams({
|
||||
south: String(params.south),
|
||||
west: String(params.west),
|
||||
north: String(params.north),
|
||||
east: String(params.east),
|
||||
});
|
||||
if (params.categories?.length) q.set('categories', params.categories.join(','));
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
return apiClient.get<PoiBboxFeatureCollection>(`/poi/by-bbox?${q.toString()}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user