Files
goodgo-platform/apps/web/components/khu-cong-nghiep/park-compare-client.tsx
Ho Ngoc Hai 5810f0be56 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>
2026-04-16 12:40:35 +07:00

340 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}