feat(web): add industrial compare page, listing search, and Mapbox park map

- Add interactive Mapbox map to /khu-cong-nghiep landing page with park markers and popups
- Build compare page at /khu-cong-nghiep/so-sanh with recharts RadarChart and detailed comparison table
- Build listing search page at /khu-cong-nghiep/cho-thue with filters for property type, lease type, area, and price
- Add IndustrialListing types, API client functions, and React Query hooks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 12:40:35 +07:00
parent 28cdd92846
commit 5810f0be56
9 changed files with 964 additions and 1 deletions

View File

@@ -0,0 +1,339 @@
'use client';
import { ArrowLeft, Factory, Plus, X } from 'lucide-react';
import * as React from 'react';
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
Legend,
Tooltip,
} from 'recharts';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import {
useIndustrialCompare,
useIndustrialParksSearch,
} from '@/lib/hooks/use-khu-cong-nghiep';
import {
type IndustrialParkDetail,
type IndustrialParkListItem,
PARK_STATUS_COLORS,
PARK_STATUS_LABELS,
REGION_LABELS,
} from '@/lib/khu-cong-nghiep-api';
const CHART_COLORS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b'];
const RADAR_METRICS = [
{ key: 'occupancy', label: 'Lấp đầy' },
{ key: 'area', label: 'Diện tích' },
{ key: 'rent', label: 'Giá thuê' },
{ key: 'infrastructure', label: 'Hạ tầng' },
{ key: 'connectivity', label: 'Kết nối' },
] as const;
function normalizeScore(park: IndustrialParkDetail, metric: string): number {
switch (metric) {
case 'occupancy':
return park.occupancyRate;
case 'area':
return Math.min((park.totalAreaHa / 1000) * 100, 100);
case 'rent': {
const rent = park.landRentUsdM2Year ?? 0;
return rent > 0 ? Math.min((rent / 150) * 100, 100) : 0;
}
case 'infrastructure': {
const count = park.infrastructure ? Object.keys(park.infrastructure).length : 0;
return Math.min((count / 10) * 100, 100);
}
case 'connectivity': {
const conns = park.connectivity ? Object.keys(park.connectivity).length : 0;
return Math.min((conns / 8) * 100, 100);
}
default:
return 0;
}
}
function buildRadarData(parks: IndustrialParkDetail[]) {
return RADAR_METRICS.map((metric) => {
const entry: Record<string, string | number> = { metric: metric.label };
parks.forEach((park, i) => {
entry[`park${i}`] = Math.round(normalizeScore(park, metric.key));
});
return entry;
});
}
export function ParkCompareClient() {
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [searchQuery, setSearchQuery] = React.useState('');
const [showPicker, setShowPicker] = React.useState(false);
const { data: searchResults } = useIndustrialParksSearch({
q: searchQuery || undefined,
limit: 20,
});
const { data: compareData, isLoading } = useIndustrialCompare(selectedIds);
const addPark = (park: IndustrialParkListItem) => {
if (selectedIds.length >= 4) return;
if (selectedIds.includes(park.id)) return;
setSelectedIds((prev) => [...prev, park.id]);
setShowPicker(false);
setSearchQuery('');
};
const removePark = (id: string) => {
setSelectedIds((prev) => prev.filter((pid) => pid !== id));
};
const radarData = compareData ? buildRadarData(compareData) : [];
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<Link href="/khu-cong-nghiep">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold md:text-3xl">So Sánh Khu Công Nghiệp</h1>
<p className="mt-1 text-muted-foreground">
Chọn 24 KCN đ so sánh chi tiết
</p>
</div>
</div>
{/* Park selection */}
<Card className="mb-6">
<CardContent className="p-4">
<div className="flex flex-wrap items-center gap-3">
{selectedIds.map((id, i) => {
const park = compareData?.find((p) => p.id === id);
return (
<Badge
key={id}
variant="secondary"
className="gap-1.5 py-1.5 pl-3 pr-2 text-sm"
style={{ borderLeft: `3px solid ${CHART_COLORS[i]}` }}
>
{park?.name ?? 'Đang tải...'}
<button
onClick={() => removePark(id)}
className="ml-1 rounded-full p-0.5 hover:bg-muted"
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
{selectedIds.length < 4 && (
<div className="relative">
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => setShowPicker(!showPicker)}
>
<Plus className="h-4 w-4" />
Thêm KCN
</Button>
{showPicker && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border bg-background shadow-lg">
<div className="p-2">
<input
type="text"
placeholder="Tìm kiếm KCN..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
autoFocus
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{searchResults?.data
.filter((p) => !selectedIds.includes(p.id))
.map((park) => (
<button
key={park.id}
onClick={() => addPark(park)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted"
>
<Factory className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{park.name}</div>
<div className="truncate text-xs text-muted-foreground">
{park.province} &middot; {park.totalAreaHa} ha
</div>
</div>
</button>
))}
{searchResults?.data.length === 0 && (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
Không tìm thấy KCN
</p>
)}
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
{/* Content */}
{selectedIds.length < 2 ? (
<div className="py-16 text-center">
<Factory className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Chọn ít nhất 2 KCN đ so sánh</p>
<p className="mt-1 text-sm text-muted-foreground">
Sử dụng nút &ldquo;Thêm KCN&rdquo; trên đ bắt đu
</p>
</div>
) : isLoading ? (
<div className="grid gap-6 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="h-96 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : compareData ? (
<div className="space-y-6">
{/* Radar Chart */}
<Card>
<CardHeader>
<CardTitle>Biểu đ radar so sánh</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<RadarChart data={radarData} cx="50%" cy="50%" outerRadius="80%">
<PolarGrid stroke="hsl(var(--border))" />
<PolarAngleAxis
dataKey="metric"
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
/>
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 10 }}
/>
{compareData.map((park, i) => (
<Radar
key={park.id}
name={park.name}
dataKey={`park${i}`}
stroke={CHART_COLORS[i]}
fill={CHART_COLORS[i]}
fillOpacity={0.15}
/>
))}
<Legend />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Comparison Table */}
<Card>
<CardHeader>
<CardTitle>Chi tiết so sánh</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="pb-3 pr-4 text-left font-medium text-muted-foreground">
Tiêu chí
</th>
{compareData.map((park, i) => (
<th
key={park.id}
className="pb-3 px-4 text-left font-medium"
style={{ borderBottom: `2px solid ${CHART_COLORS[i]}` }}
>
{park.name}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
<CompareRow label="Trạng thái" parks={compareData} render={(p) => (
<Badge className={PARK_STATUS_COLORS[p.status]} variant="secondary">
{PARK_STATUS_LABELS[p.status]}
</Badge>
)} />
<CompareRow label="Vùng miền" parks={compareData} render={(p) => REGION_LABELS[p.region]} />
<CompareRow label="Tỉnh/TP" parks={compareData} render={(p) => p.province} />
<CompareRow label="Tổng diện tích" parks={compareData} render={(p) => `${p.totalAreaHa.toLocaleString()} ha`} />
<CompareRow label="Diện tích cho thuê" parks={compareData} render={(p) => `${p.leasableAreaHa.toLocaleString()} ha`} />
<CompareRow label="Tỷ lệ lấp đầy" parks={compareData} render={(p) => `${p.occupancyRate}%`} />
<CompareRow label="Còn trống" parks={compareData} render={(p) => `${p.remainingAreaHa.toLocaleString()} ha`} />
<CompareRow label="Số DN" parks={compareData} render={(p) => String(p.tenantCount)} />
<CompareRow label="Năm thành lập" parks={compareData} render={(p) => p.establishedYear ? String(p.establishedYear) : '—'} />
<CompareRow label="Thuê đất (USD/m²/năm)" parks={compareData} render={(p) => p.landRentUsdM2Year ? `$${p.landRentUsdM2Year}` : '—'} />
<CompareRow label="Nhà xưởng (USD/m²/th)" parks={compareData} render={(p) => p.rbfRentUsdM2Month ? `$${p.rbfRentUsdM2Month}` : '—'} />
<CompareRow label="Phí quản lý" parks={compareData} render={(p) => p.managementFeeUsd ? `$${p.managementFeeUsd}` : '—'} />
<CompareRow label="Chủ đầu tư" parks={compareData} render={(p) => p.developer} />
<CompareRow label="Ngành mục tiêu" parks={compareData} render={(p) => (
<div className="flex flex-wrap gap-1">
{p.targetIndustries.slice(0, 4).map((ind) => (
<Badge key={ind} variant="outline" className="text-xs">{ind}</Badge>
))}
{p.targetIndustries.length > 4 && (
<Badge variant="outline" className="text-xs">+{p.targetIndustries.length - 4}</Badge>
)}
</div>
)} />
<CompareRow label="Chứng chỉ" parks={compareData} render={(p) => (
<div className="flex flex-wrap gap-1">
{p.certifications?.map((cert) => (
<Badge key={cert} variant="outline" className="text-xs">{cert}</Badge>
)) ?? '—'}
</div>
)} />
</tbody>
</table>
</CardContent>
</Card>
</div>
) : null}
</div>
);
}
function CompareRow({
label,
parks,
render,
}: {
label: string;
parks: IndustrialParkDetail[];
render: (park: IndustrialParkDetail) => React.ReactNode;
}) {
return (
<tr>
<td className="py-3 pr-4 font-medium text-muted-foreground whitespace-nowrap">{label}</td>
{parks.map((park) => (
<td key={park.id} className="px-4 py-3">{render(park)}</td>
))}
</tr>
);
}