Files
goodgo-platform/apps/web/components/khu-cong-nghiep/park-compare-client.tsx
Ho Ngoc Hai 36a9b00cf1 feat(industrial): update TypeScript types for Float→Decimal USD field migration (GOO-27)
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>
2026-04-23 00:34:40 +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 != 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 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>
);
}