Migration SQL (20260422120000_industrial_usd_to_decimal) and Prisma schema already reflected Decimal(18,4). This commit completes the TypeScript / frontend layer. API changes: - Domain repo interfaces (IndustrialListingListItem, IndustrialListingDetailData, IndustrialParkListItem, IndustrialParkDetailData, IndustrialMarketData): USD money fields changed from number|null → string|null (PostgreSQL numeric serialises as string in raw query results) - Raw DB interface types in Prisma repositories updated to string|null for Decimal columns - toDomain() mappers: parseFloat() added where entity props require number|null for business-logic arithmetic - estimate-industrial-rent handler: Number() cast on Prisma ORM Decimal objects before arithmetic and comparisons Web changes: - khu-cong-nghiep-api.ts: IndustrialParkListItem, IndustrialParkDetail, IndustrialListingItem, IndustrialMarketData USD fields → string|null with JSDoc - listing-card.tsx: parseFloat() wrapping for priceUsdM2/totalLeasePrice display - park-compare-client.tsx: parseFloat() for landRentUsdM2Year in radar score Note: pre-existing test failures in filter-bar/login/search specs are unrelated to this migration (confirmed present on branch before this change). Co-Authored-By: Paperclip <noreply@paperclip.ing>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
'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 != null ? parseFloat(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>
|
||
);
|
||
}
|