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:
339
apps/web/components/khu-cong-nghiep/park-compare-client.tsx
Normal file
339
apps/web/components/khu-cong-nghiep/park-compare-client.tsx
Normal 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 2–4 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} · {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 “Thêm KCN” ở 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user