feat(industrial): OSM bulk import + bbox map + admin review (PR 2-4/4)
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Pulls every `landuse=industrial` feature from OpenStreetMap into the
IndustrialPark catalog and surfaces it on the public KCN map. Admins can
promote raw OSM rows into the public catalog or lock individual fields
to protect them from the monthly reconciliation sync.
PR 2 — Bulk import script (scripts/sync-osm-industrial-parks.ts):
• Splits Vietnam into 4 chunks (north / northCentral / southCentral /
south) to stay under Overpass 504 timeouts.
• Posts to overpass-api.de with form-encoded body, converts via
osmtogeojson, derives centroid + area via @turf/centroid + @turf/area.
• Upsert keyed on osmId. Honours `osmLocked` (skip row entirely) and
`lockedFields[]` (skip individual columns) so admin edits survive.
• Inserts use $executeRawUnsafe with ST_SetSRID(ST_MakePoint, 4326)
because Prisma can't manage the Unsupported geometry NOT NULL column.
• CLI flags: --dry-run, --chunk=NAME.
PR 3 — Bbox spatial API + Mapbox layer:
• GET /industrial/parks/by-bbox returns a GeoJSON FeatureCollection
filtered by ST_MakeEnvelope. Sends Point-only at zoom < 12,
MultiPolygon outline at zoom >= 12 to keep payloads light.
• Public consumers see MANUAL + OSM_PROMOTED only; admins can pass
includeOsmRaw=true to also see raw OSM imports.
• OsmParkBboxMap component drives Mapbox from viewport moveend with
AbortController-debounced fetches, clusters at zoom < 12, expands
via getClusterExpansionZoom (callback-style API).
• /khu-cong-nghiep page now uses the bbox map in map + split views.
PR 4 — Admin review queue + monthly cron:
• Commands: PromoteOsmPark (OSM → OSM_PROMOTED + isPublic=true,
optional lockFields), LockOsmPark (toggle row-level skip flag).
• Query: ListOsmPending lists rows with dataSource='OSM' for review.
• OsmSyncCronService runs `0 2 1 * *` Asia/Ho_Chi_Minh and spawns
sync-osm-industrial-parks.ts per chunk. Skipped unless
OSM_SYNC_ENABLED=true so dev never accidentally hits Overpass.
• New admin page /admin/industrial/osm-review: searchable table,
promote dialog with quick-pick lock fields (name, developer,
description, etc.) plus a free-text fallback, lock/unlock toggle,
deep-link to openstreetmap.org for verification.
Repository changes:
• PrismaIndustrialParkRepository now filters public queries to
`isPublic = true AND dataSource IN (MANUAL, OSM_PROMOTED)` so raw
OSM rows stay hidden from end users.
• Added *.rdb to .gitignore (Redis dump local artefact).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
Lock,
|
||||
LockOpen,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
X,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import {
|
||||
industrialApi,
|
||||
type OsmPendingItem,
|
||||
type OsmPendingResult,
|
||||
} from '@/lib/khu-cong-nghiep-api';
|
||||
|
||||
/**
|
||||
* Admin OSM review queue. Lists parks with `dataSource = 'OSM'` (raw imports
|
||||
* from the monthly Overpass sync). Admins decide what to do with each row:
|
||||
*
|
||||
* - Promote → flips `dataSource` to `OSM_PROMOTED` and `isPublic = true`,
|
||||
* so the row shows up in the public catalog. Optionally lock specific
|
||||
* fields so the next sync run won't overwrite them.
|
||||
* - Lock / Unlock → toggles `osmLocked`. When locked, the row is skipped
|
||||
* entirely by the sync cron.
|
||||
*
|
||||
* Fields that admins commonly want to lock after edits: `name`, `developer`,
|
||||
* `description`, `targetIndustries`. We surface these as quick-pick checkboxes
|
||||
* in the promote dialog, plus a free-text fallback for anything else.
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: 'name', label: 'Tên KCN' },
|
||||
{ key: 'developer', label: 'Chủ đầu tư' },
|
||||
{ key: 'description', label: 'Mô tả' },
|
||||
{ key: 'targetIndustries', label: 'Ngành mục tiêu' },
|
||||
{ key: 'totalAreaHa', label: 'Diện tích' },
|
||||
{ key: 'status', label: 'Trạng thái' },
|
||||
];
|
||||
|
||||
function formatTags(tags: Record<string, string> | null): string {
|
||||
if (!tags) return '—';
|
||||
// Surface the most useful keys first, then anything else, capped to keep
|
||||
// the cell readable. Tag values are user-generated on OSM so we trim hard.
|
||||
const priorityKeys = ['name', 'name:vi', 'name:en', 'operator', 'website'];
|
||||
const ordered = [
|
||||
...priorityKeys.filter((k) => k in tags),
|
||||
...Object.keys(tags).filter((k) => !priorityKeys.includes(k)),
|
||||
];
|
||||
return ordered
|
||||
.slice(0, 4)
|
||||
.map((k) => `${k}=${String(tags[k]).slice(0, 30)}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export default function AdminOsmReviewPage() {
|
||||
const [result, setResult] = useState<OsmPendingResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Filters
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [provinceFilter, setProvinceFilter] = useState('');
|
||||
|
||||
// Promote dialog state
|
||||
const [promoteTarget, setPromoteTarget] = useState<OsmPendingItem | null>(null);
|
||||
const [lockFields, setLockFields] = useState<Set<string>>(new Set());
|
||||
const [extraField, setExtraField] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const fetchQueue = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await industrialApi.listOsmPending({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
q: search || undefined,
|
||||
province: provinceFilter || undefined,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi OSM');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, provinceFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue();
|
||||
}, [fetchQueue]);
|
||||
|
||||
const submitSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
setSearch(searchInput.trim());
|
||||
};
|
||||
|
||||
const handleToggleLock = async (item: OsmPendingItem) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await industrialApi.lockOsm(item.id, !item.osmLocked);
|
||||
fetchQueue();
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : 'Không thể cập nhật trạng thái lock');
|
||||
}
|
||||
};
|
||||
|
||||
const openPromoteDialog = (item: OsmPendingItem) => {
|
||||
setPromoteTarget(item);
|
||||
// Default: lock the name (so the next OSM sync doesn't rename it back to
|
||||
// whatever Overpass has). Admins can uncheck if they want OSM to win.
|
||||
setLockFields(new Set(['name']));
|
||||
setExtraField('');
|
||||
setActionError(null);
|
||||
};
|
||||
|
||||
const closePromoteDialog = () => {
|
||||
setPromoteTarget(null);
|
||||
setLockFields(new Set());
|
||||
setExtraField('');
|
||||
};
|
||||
|
||||
const handlePromote = async () => {
|
||||
if (!promoteTarget) return;
|
||||
setActionLoading(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const fields = Array.from(lockFields);
|
||||
const extras = extraField
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
await industrialApi.promoteOsm(promoteTarget.id, [...fields, ...extras]);
|
||||
closePromoteDialog();
|
||||
fetchQueue();
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : 'Promote thất bại. Vui lòng thử lại.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLockField = (key: string) => {
|
||||
setLockFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{actionError && (
|
||||
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
<span>{actionError}</span>
|
||||
<button onClick={() => setActionError(null)} className="ml-2" aria-label="Đóng">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-heading-md font-semibold tracking-tight">Review KCN từ OpenStreetMap</h1>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Xét duyệt các KCN nhập từ OSM (chưa public). Promote → public catalog hoặc lock để giữ nguyên dữ liệu.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchQueue} disabled={loading}>
|
||||
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Làm mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="shadow-elevation-1">
|
||||
<CardContent className="p-4">
|
||||
<form className="flex flex-wrap items-end gap-3" onSubmit={submitSearch}>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="mb-1 block text-xs text-foreground-dim">Tìm kiếm</label>
|
||||
<Input
|
||||
placeholder="Tên KCN, chủ đầu tư..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="mb-1 block text-xs text-foreground-dim">Tỉnh / TP</label>
|
||||
<Input
|
||||
placeholder="Bắc Ninh, Đồng Nai..."
|
||||
value={provinceFilter}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setProvinceFilter(e.target.value);
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
Tìm
|
||||
</Button>
|
||||
{(search || provinceFilter) && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearch('');
|
||||
setProvinceFilter('');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Xóa bộ lọc
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="shadow-elevation-1 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-foreground-muted" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchQueue}>
|
||||
Thử lại
|
||||
</Button>
|
||||
</div>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||
<CheckCircle className="h-8 w-8 text-signal-up" />
|
||||
<p className="text-sm text-foreground-muted">Không có KCN nào trong hàng đợi</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-sticky-header bg-background-elevated">
|
||||
<TableRow className="border-b border-border-strong">
|
||||
<TableHead className="text-heading-xs uppercase text-foreground-muted">
|
||||
Tên KCN
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell text-heading-xs uppercase text-foreground-muted">
|
||||
Tỉnh
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell text-heading-xs uppercase text-foreground-muted text-right">
|
||||
Diện tích (ha)
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell text-heading-xs uppercase text-foreground-muted">
|
||||
OSM
|
||||
</TableHead>
|
||||
<TableHead className="hidden xl:table-cell text-heading-xs uppercase text-foreground-muted">
|
||||
Tags
|
||||
</TableHead>
|
||||
<TableHead className="text-heading-xs uppercase text-foreground-muted">
|
||||
Trạng thái
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-heading-xs uppercase text-foreground-muted">
|
||||
Hành động
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.data.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="h-row-compact border-b border-border hover:bg-background-surface transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium max-w-[280px] truncate text-sm">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.nameEn && (
|
||||
<div className="text-xs text-foreground-dim max-w-[280px] truncate">
|
||||
{item.nameEn}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-sm text-foreground-muted">
|
||||
{item.province}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell font-mono text-data-sm tabular-nums text-right">
|
||||
{item.totalAreaHa
|
||||
? new Intl.NumberFormat('vi-VN', {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(item.totalAreaHa)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell font-mono text-data-sm">
|
||||
<div className="flex items-center gap-1 text-foreground-dim">
|
||||
{item.osmType?.toLowerCase() ?? '—'}/{item.osmId}
|
||||
{item.latitude != null && item.longitude != null && (
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/${(item.osmType ?? 'way').toLowerCase()}/${item.osmId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground"
|
||||
aria-label="Mở trên openstreetmap.org"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-xs text-foreground-dim max-w-[260px] truncate">
|
||||
{formatTags(item.osmTags)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.osmLocked ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-pill bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-200">
|
||||
<Lock className="h-3 w-3" />
|
||||
Locked
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-pill bg-background-surface px-2 py-0.5 text-xs font-medium text-foreground-muted ring-1 ring-inset ring-border">
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{item.latitude != null && item.longitude != null && (
|
||||
<Link
|
||||
href={`/khu-cong-nghiep/${item.slug}` as never}
|
||||
target="_blank"
|
||||
className="rounded p-1 text-foreground-muted hover:bg-background-surface transition-colors"
|
||||
aria-label={`Xem KCN: ${item.name}`}
|
||||
title="Mở trang chi tiết"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
title={item.osmLocked ? 'Bỏ khóa OSM sync' : 'Khóa OSM sync'}
|
||||
onClick={() => handleToggleLock(item)}
|
||||
className="rounded p-1 text-foreground-muted hover:bg-background-surface transition-colors"
|
||||
aria-label={
|
||||
item.osmLocked ? `Bỏ khóa: ${item.name}` : `Khóa: ${item.name}`
|
||||
}
|
||||
>
|
||||
{item.osmLocked ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
title="Promote → public"
|
||||
onClick={() => openPromoteDialog(item)}
|
||||
className="rounded p-1 text-signal-up hover:bg-signal-up/10 transition-colors"
|
||||
aria-label={`Promote KCN: ${item.name}`}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-border px-4 py-2.5">
|
||||
<span className="font-mono text-data-sm text-foreground-muted">
|
||||
Trang {result.page}/{result.totalPages} · {result.total} KCN
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={page >= result.totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Promote dialog */}
|
||||
<Dialog
|
||||
open={!!promoteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closePromoteDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Promote KCN từ OSM</DialogTitle>
|
||||
<DialogDescription>
|
||||
<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.
|
||||
</span>
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-foreground-muted">Khóa các trường:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{QUICK_LOCK_FIELDS.map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm cursor-pointer hover:bg-background-surface"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lockFields.has(key)}
|
||||
onChange={() => toggleLockField(key)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-foreground-muted">
|
||||
Trường tùy chỉnh (cách nhau bởi dấu phẩy)
|
||||
</p>
|
||||
<Input
|
||||
placeholder="vd: occupancyRate, leasableAreaHa"
|
||||
value={extraField}
|
||||
onChange={(e) => setExtraField(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closePromoteDialog} disabled={actionLoading}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button onClick={handlePromote} disabled={actionLoading}>
|
||||
{actionLoading ? 'Đang xử lý...' : 'Promote'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ href: '/admin/audit-log' as const, label: 'Nhật ký kiểm toán', icon: ScrollText },
|
||||
{ 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/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { Factory, Map as MapIcon, List, Columns } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
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';
|
||||
import { ParkMap } from '@/components/khu-cong-nghiep/park-map';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep';
|
||||
import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api';
|
||||
@@ -111,12 +111,12 @@ export default function KhuCongNghiepPage() {
|
||||
{data.total} khu công nghiệp được tìm thấy
|
||||
</p>
|
||||
|
||||
{/* Map-only view */}
|
||||
{/* Map-only view — bbox-driven, loads ALL parks in viewport */}
|
||||
{viewMode === 'map' && (
|
||||
<ParkMap parks={data.data} className="h-[calc(100vh-260px)]" />
|
||||
<OsmParkBboxMap className="h-[calc(100vh-260px)]" />
|
||||
)}
|
||||
|
||||
{/* Split view: list left, sticky map right (lg+ only) */}
|
||||
{/* Split view: list left, sticky bbox map right (lg+ only) */}
|
||||
{viewMode === 'split' && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="overflow-auto" style={{ maxHeight: 'calc(100vh - 220px)' }}>
|
||||
@@ -127,10 +127,7 @@ export default function KhuCongNghiepPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<ParkMap
|
||||
parks={data.data}
|
||||
className="sticky top-20 h-[calc(100vh-220px)]"
|
||||
/>
|
||||
<OsmParkBboxMap className="sticky top-20 h-[calc(100vh-220px)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user