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>
182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
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 { Button } from '@/components/ui/button';
|
|
import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep';
|
|
import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api';
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
type ViewMode = 'list' | 'map' | 'split';
|
|
|
|
export default function KhuCongNghiepPage() {
|
|
const [filters, setFilters] = React.useState<SearchIndustrialParksParams>({
|
|
page: 1,
|
|
limit: PAGE_SIZE,
|
|
});
|
|
const [viewMode, setViewMode] = React.useState<ViewMode>('split');
|
|
|
|
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
|
|
|
|
const handleFilterChange = (newFilters: SearchIndustrialParksParams) => {
|
|
setFilters({ ...newFilters, limit: PAGE_SIZE });
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setFilters((prev) => ({ ...prev, page }));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl px-4 py-6">
|
|
{/* Page header */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold md:text-3xl">Khu Công Nghiệp Việt Nam</h1>
|
|
<p className="mt-1 text-muted-foreground">
|
|
Tìm kiếm và so sánh các khu công nghiệp trên toàn quốc
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<ParkFilterBar params={filters} onChange={handleFilterChange} />
|
|
|
|
{/* View mode toggle */}
|
|
<div className="mt-4 flex justify-end">
|
|
<div className="inline-flex gap-1 rounded-lg border p-1">
|
|
<Button
|
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
|
size="sm"
|
|
className="gap-1.5"
|
|
onClick={() => setViewMode('list')}
|
|
aria-pressed={viewMode === 'list'}
|
|
>
|
|
<List className="h-4 w-4" />
|
|
Danh sách
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
|
size="sm"
|
|
className="gap-1.5"
|
|
onClick={() => setViewMode('map')}
|
|
aria-pressed={viewMode === 'map'}
|
|
>
|
|
<MapIcon className="h-4 w-4" />
|
|
Bản đồ
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'split' ? 'default' : 'ghost'}
|
|
size="sm"
|
|
className="hidden gap-1.5 lg:inline-flex"
|
|
onClick={() => setViewMode('split')}
|
|
aria-pressed={viewMode === 'split'}
|
|
>
|
|
<Columns className="h-4 w-4" />
|
|
Chia đôi
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<div className="mt-4">
|
|
{isLoading ? (
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="h-72 animate-pulse rounded-lg bg-muted"
|
|
/>
|
|
))}
|
|
</div>
|
|
) : isError ? (
|
|
<div className="py-12 text-center">
|
|
<p className="text-muted-foreground">
|
|
Không thể tải danh sách khu công nghiệp. Vui lòng thử lại.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => setFilters({ ...filters })}
|
|
>
|
|
Thử lại
|
|
</Button>
|
|
</div>
|
|
) : data && data.data.length > 0 ? (
|
|
<>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
{data.total} khu công nghiệp được tìm thấy
|
|
</p>
|
|
|
|
{/* Map-only view — bbox-driven, loads ALL parks in viewport */}
|
|
{viewMode === 'map' && (
|
|
<OsmParkBboxMap className="h-[calc(100vh-260px)]" />
|
|
)}
|
|
|
|
{/* 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)' }}>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{data.data.map((park) => (
|
|
<ParkCard key={park.id} park={park} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="hidden lg:block">
|
|
<OsmParkBboxMap className="sticky top-20 h-[calc(100vh-220px)]" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* List-only view */}
|
|
{viewMode === 'list' && (
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
{data.data.map((park) => (
|
|
<ParkCard key={park.id} park={park} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination — show in list/split mode only */}
|
|
{viewMode !== 'map' && data.totalPages > 1 && (
|
|
<div className="mt-8 flex items-center justify-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={filters.page === 1}
|
|
onClick={() => handlePageChange((filters.page || 1) - 1)}
|
|
>
|
|
Trước
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Trang {data.page} / {data.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={data.page >= data.totalPages}
|
|
onClick={() => handlePageChange((filters.page || 1) + 1)}
|
|
>
|
|
Sau
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="py-12 text-center">
|
|
<Factory className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
|
<p className="mt-4 text-lg font-medium">Không tìm thấy khu công nghiệp</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Thử thay đổi bộ lọc để tìm kiếm nhiều hơn
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|