feat(web): add khu-cong-nghiep, chuyen-nhuong, and reports pages

Add three new frontend page sections:
- Industrial parks (khu-cong-nghiep): listing, detail, filter bar
- Transfer listings (chuyen-nhuong): search, category tabs, detail
- AI reports dashboard: list, create, viewer with TOC

Includes components, API clients, hooks, server helpers, i18n keys,
navigation links in public and dashboard layouts, and lint fixes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:07:45 +07:00
parent 62a8842193
commit 7ce651fce5
30 changed files with 2874 additions and 1 deletions

View File

@@ -0,0 +1,256 @@
'use client';
import {
Eye,
Heart,
MapPin,
MessageCircle,
Phone,
User,
} from 'lucide-react';
import * as React from 'react';
import { TransferItemTable } from '@/components/chuyen-nhuong/transfer-item-table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
type TransferListingDetail,
CATEGORY_ICONS,
CATEGORY_LABELS,
STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api';
import { cn } from '@/lib/utils';
interface ChuyenNhuongDetailClientProps {
listing: TransferListingDetail;
}
function formatVND(value: string): string {
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
}
export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) {
const statusColor =
listing.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
listing.status === 'RESERVED' ? 'bg-amber-100 text-amber-800' :
listing.status === 'SOLD' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800';
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Header */}
<div className="mb-6">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge className={cn('text-xs', statusColor)} variant="secondary">
{STATUS_LABELS[listing.status]}
</Badge>
<Badge variant="outline" className="text-xs">
{CATEGORY_ICONS[listing.category]} {CATEGORY_LABELS[listing.category]}
</Badge>
{listing.isNegotiable && (
<Badge className="bg-blue-100 text-blue-800 text-xs" variant="secondary">
Thương lượng
</Badge>
)}
</div>
<h1 className="text-2xl font-bold md:text-3xl">{listing.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{listing.address}, {listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city}
</span>
</div>
</div>
{/* Quick stats */}
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
<QuickStat
label="Giá yêu cầu"
value={formatVND(listing.askingPriceVND)}
valueClassName="text-primary"
/>
{listing.aiEstimatePriceVND && (
<QuickStat
label="Giá AI ước tính"
value={formatVND(listing.aiEstimatePriceVND)}
/>
)}
{listing.areaM2 && (
<QuickStat
label="Diện tích"
value={`${listing.areaM2} m\u00b2`}
/>
)}
<QuickStat
icon={<Eye className="h-5 w-5" />}
label="Lượt xem"
value={`${listing.viewCount}`}
/>
<QuickStat
icon={<Heart className="h-5 w-5" />}
label="Lượt lưu"
value={`${listing.saveCount}`}
/>
<QuickStat
icon={<MessageCircle className="h-5 w-5" />}
label="Liên hệ"
value={`${listing.inquiryCount}`}
/>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="space-y-6 lg:col-span-2">
{/* Description */}
{listing.description && (
<Card>
<CardHeader>
<CardTitle> tả</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{listing.description}
</p>
</CardContent>
</Card>
)}
{/* Items table */}
<Card>
<CardHeader>
<CardTitle>Danh sách vật phẩm ({listing.items.length})</CardTitle>
</CardHeader>
<CardContent>
<TransferItemTable items={listing.items} />
</CardContent>
</Card>
{/* Business info */}
{(listing.businessType || listing.monthlyRentVND || listing.remainingLeaseMo) && (
<Card>
<CardHeader>
<CardTitle>Thông tin kinh doanh</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{listing.businessType && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Loại hình kinh doanh</span>
<span className="font-medium">{listing.businessType}</span>
</div>
)}
{listing.monthlyRentVND && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tiền thuê hàng tháng</span>
<span className="font-medium">{formatVND(listing.monthlyRentVND)}</span>
</div>
)}
{listing.depositMonths != null && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cọc</span>
<span className="font-medium">{listing.depositMonths} tháng</span>
</div>
)}
{listing.remainingLeaseMo != null && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Hợp đng còn lại</span>
<span className="font-medium">{listing.remainingLeaseMo} tháng</span>
</div>
)}
{listing.footTraffic && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Lưu lượng khách</span>
<span className="font-medium">{listing.footTraffic}</span>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Price card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Giá chuyển nhượng</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Giá yêu cầu</span>
<span className="text-lg font-bold text-primary">
{formatVND(listing.askingPriceVND)}
</span>
</div>
{listing.aiEstimatePriceVND && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Giá AI ưc tính</span>
<span className="font-semibold">
{formatVND(listing.aiEstimatePriceVND)}
</span>
</div>
)}
{listing.aiConfidence != null && (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Đ tin cậy AI</span>
<span className="font-semibold">{Math.round(listing.aiConfidence * 100)}%</span>
</div>
)}
{listing.isNegotiable && (
<p className="text-xs text-muted-foreground">Giá thể thương lượng</p>
)}
</CardContent>
</Card>
{/* Contact card */}
<Card className="lg:sticky lg:top-20">
<CardHeader>
<CardTitle className="text-base">Thông tin liên hệ</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{listing.contactName ? (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
<User className="h-4 w-4 text-primary" />
</div>
<p className="text-sm font-medium">{listing.contactName}</p>
</div>
) : null}
{listing.contactPhone ? (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium">{listing.contactPhone}</p>
</div>
) : null}
{!listing.contactName && !listing.contactPhone && (
<p className="text-sm text-muted-foreground">Liên hệ qua hệ thống</p>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// ─── Sub-components ────────────────────────────────────────
function QuickStat({
icon,
label,
value,
valueClassName,
}: {
icon?: React.ReactNode;
label: string;
value: string;
valueClassName?: string;
}) {
return (
<div className="flex items-center gap-2">
{icon && <div className="text-muted-foreground">{icon}</div>}
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn('text-sm font-semibold', valueClassName)}>{value}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
type TransferItemData,
CATEGORY_LABELS,
CONDITION_COLORS,
CONDITION_LABELS,
} from '@/lib/chuyen-nhuong-api';
interface TransferItemTableProps {
items: TransferItemData[];
}
function formatVND(value: string): string {
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
}
export function TransferItemTable({ items }: TransferItemTableProps) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Chưa danh sách vật phẩm.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="px-3 py-2 text-left font-medium">Tên</th>
<th className="px-3 py-2 text-left font-medium">Loại</th>
<th className="px-3 py-2 text-left font-medium">Tình trạng</th>
<th className="px-3 py-2 text-left font-medium">Thương hiệu</th>
<th className="px-3 py-2 text-right font-medium">SL</th>
<th className="px-3 py-2 text-right font-medium">Giá yêu cầu</th>
<th className="px-3 py-2 text-right font-medium">Giá AI</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b">
<td className="px-3 py-2">
<div>
<p className="font-medium">{item.name}</p>
{item.modelName && (
<p className="text-xs text-muted-foreground">{item.modelName}</p>
)}
</div>
</td>
<td className="px-3 py-2 text-muted-foreground">
{CATEGORY_LABELS[item.category]}
</td>
<td className="px-3 py-2">
<Badge className={CONDITION_COLORS[item.condition]} variant="secondary">
{CONDITION_LABELS[item.condition]}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">
{item.brand ?? '—'}
</td>
<td className="px-3 py-2 text-right">{item.quantity}</td>
<td className="px-3 py-2 text-right font-medium">
{formatVND(item.askingPriceVND)}
</td>
<td className="px-3 py-2 text-right text-muted-foreground">
{item.aiEstimatePriceVND ? (
<span title={item.aiConfidence ? `Độ tin cậy: ${Math.round(item.aiConfidence * 100)}%` : undefined}>
{formatVND(item.aiEstimatePriceVND)}
</span>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { Eye, MapPin, Package } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import {
type TransferListingListItem,
CATEGORY_ICONS,
CATEGORY_LABELS,
STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api';
interface TransferListingCardProps {
listing: TransferListingListItem;
}
function formatVND(value: string): string {
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
}
export function TransferListingCard({ listing }: TransferListingCardProps) {
const statusColor =
listing.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
listing.status === 'RESERVED' ? 'bg-amber-100 text-amber-800' :
listing.status === 'SOLD' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800';
return (
<Link href={`/chuyen-nhuong/${listing.id}`}>
<Card className="group h-full transition-shadow hover:shadow-lg">
<CardContent className="p-5">
{/* Header */}
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 font-semibold text-foreground group-hover:text-primary">
{listing.title}
</h3>
</div>
<Badge className={statusColor} variant="secondary">
{STATUS_LABELS[listing.status]}
</Badge>
</div>
{/* Category */}
<div className="mb-3">
<Badge variant="outline" className="text-xs">
{CATEGORY_ICONS[listing.category]} {CATEGORY_LABELS[listing.category]}
</Badge>
</div>
{/* Location */}
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="line-clamp-1">{listing.district}, {listing.city}</span>
</div>
{/* Price */}
<div className="mb-3">
<p className="text-lg font-bold text-primary">
{formatVND(listing.askingPriceVND)}
</p>
{listing.isNegotiable && (
<span className="text-xs text-muted-foreground">Thương lượng</span>
)}
</div>
{/* Stats grid */}
<div className="mb-3 grid grid-cols-3 gap-2">
<div className="rounded-md bg-muted p-2 text-center">
<div className="text-xs text-muted-foreground">Món</div>
<div className="flex items-center justify-center gap-1 font-semibold">
<Package className="h-3 w-3" />
{listing.itemCount}
</div>
</div>
<div className="rounded-md bg-muted p-2 text-center">
<div className="text-xs text-muted-foreground">Lượt xem</div>
<div className="flex items-center justify-center gap-1 font-semibold">
<Eye className="h-3 w-3" />
{listing.viewCount}
</div>
</div>
{listing.areaM2 && (
<div className="rounded-md bg-muted p-2 text-center">
<div className="text-xs text-muted-foreground">Diện tích</div>
<div className="font-semibold">{listing.areaM2} m&sup2;</div>
</div>
)}
</div>
{/* Footer */}
{listing.publishedAt && (
<div className="border-t pt-3 text-xs text-muted-foreground">
Đăng {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
</div>
)}
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,407 @@
'use client';
import {
Building2,
Calendar,
Download,
Factory,
FileText,
Globe,
MapPin,
Ruler,
Users,
Zap,
} from 'lucide-react';
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type IndustrialParkDetail,
PARK_STATUS_COLORS,
PARK_STATUS_LABELS,
REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
import { cn } from '@/lib/utils';
type Tab = 'infrastructure' | 'connectivity' | 'incentives' | 'tenants' | 'documents';
const TABS: { key: Tab; label: string }[] = [
{ key: 'infrastructure', label: 'Hạ tầng' },
{ key: 'connectivity', label: 'Kết nối giao thông' },
{ key: 'incentives', label: 'Ưu đãi đầu tư' },
{ key: 'tenants', label: 'Doanh nghiệp' },
{ key: 'documents', label: 'Tài liệu' },
];
interface KhuCongNghiepDetailClientProps {
park: IndustrialParkDetail;
}
export function KhuCongNghiepDetailClient({ park }: KhuCongNghiepDetailClientProps) {
const [activeTab, setActiveTab] = React.useState<Tab>('infrastructure');
const occupancyColor =
park.occupancyRate >= 90 ? 'text-red-600' :
park.occupancyRate >= 70 ? 'text-amber-600' :
'text-green-600';
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Header */}
<div className="mb-6">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge className={cn('text-xs', PARK_STATUS_COLORS[park.status])} variant="secondary">
{PARK_STATUS_LABELS[park.status]}
</Badge>
<Badge variant="outline" className="text-xs">
{REGION_LABELS[park.region]}
</Badge>
{park.isVerified && (
<Badge className="bg-blue-100 text-blue-800 text-xs" variant="secondary">
Đã xác minh
</Badge>
)}
</div>
<h1 className="text-2xl font-bold md:text-3xl">{park.name}</h1>
{park.nameEn && (
<p className="mt-1 text-lg text-muted-foreground">{park.nameEn}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{park.address}, {park.district}, {park.province}
</span>
<span className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
{park.developer}
</span>
{park.establishedYear && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Thành lập: {park.establishedYear}
</span>
)}
</div>
</div>
{/* Quick stats */}
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
<QuickStat
icon={<Ruler className="h-5 w-5" />}
label="Tổng diện tích"
value={`${park.totalAreaHa.toLocaleString()} ha`}
/>
<QuickStat
icon={<Factory className="h-5 w-5" />}
label="DT cho thuê"
value={`${park.leasableAreaHa.toLocaleString()} ha`}
/>
<QuickStat
label="Tỷ lệ lấp đầy"
value={`${park.occupancyRate}%`}
valueClassName={occupancyColor}
/>
<QuickStat
label="Còn trống"
value={`${park.remainingAreaHa.toLocaleString()} ha`}
/>
<QuickStat
icon={<Users className="h-5 w-5" />}
label="Doanh nghiệp"
value={`${park.tenantCount}`}
/>
<QuickStat
label="Tin đăng"
value={`${park.listingCount}`}
/>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="space-y-6 lg:col-span-2">
{/* Description */}
{park.description && (
<Card>
<CardHeader>
<CardTitle>Giới thiệu</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{park.description}
</p>
</CardContent>
</Card>
)}
{/* Target industries */}
{park.targetIndustries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ngành nghề thu hút</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{park.targetIndustries.map((industry) => (
<Badge key={industry} variant="secondary">
{industry}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Certifications */}
{park.certifications && park.certifications.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Chứng nhận</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{park.certifications.map((cert) => (
<Badge key={cert} variant="outline" className="gap-1">
<Globe className="h-3 w-3" />
{cert}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs */}
<div>
<div className="flex gap-1 overflow-x-auto border-b" role="tablist">
{TABS.map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={activeTab === tab.key}
className={cn(
'shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="mt-4">
{activeTab === 'infrastructure' && <InfrastructureTab park={park} />}
{activeTab === 'connectivity' && <ConnectivityTab park={park} />}
{activeTab === 'incentives' && <IncentivesTab park={park} />}
{activeTab === 'tenants' && <TenantsTab park={park} />}
{activeTab === 'documents' && <DocumentsTab park={park} />}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Rent info */}
<Card>
<CardHeader>
<CardTitle className="text-base">Giá thuê</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{park.landRentUsdM2Year != null ? (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Thuê đt</span>
<span className="font-semibold text-primary">
${park.landRentUsdM2Year}/m²/năm
</span>
</div>
) : null}
{park.rbfRentUsdM2Month != null ? (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Nhà xưởng xây sẵn</span>
<span className="font-semibold">${park.rbfRentUsdM2Month}/m²/tháng</span>
</div>
) : null}
{park.rbwRentUsdM2Month != null ? (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Nhà kho</span>
<span className="font-semibold">${park.rbwRentUsdM2Month}/m²/tháng</span>
</div>
) : null}
{park.managementFeeUsd != null ? (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm text-muted-foreground">Phí quản </span>
<span className="font-semibold">${park.managementFeeUsd}/m²/năm</span>
</div>
) : null}
{park.landRentUsdM2Year == null &&
park.rbfRentUsdM2Month == null &&
park.rbwRentUsdM2Month == null && (
<p className="text-sm text-muted-foreground">Liên hệ đ biết giá thuê</p>
)}
</CardContent>
</Card>
{/* Developer / Operator */}
<Card className="lg:sticky lg:top-20">
<CardHeader>
<CardTitle className="text-base">Thông tin quản </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs text-muted-foreground">Chủ đu </p>
<div className="mt-1 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="h-4 w-4 text-primary" />
</div>
<p className="text-sm font-medium">{park.developer}</p>
</div>
</div>
{park.operator && (
<div>
<p className="text-xs text-muted-foreground">Đơn vị vận hành</p>
<p className="mt-1 text-sm font-medium">{park.operator}</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// ─── Sub-components ────────────────────────────────────────
function QuickStat({
icon,
label,
value,
valueClassName,
}: {
icon?: React.ReactNode;
label: string;
value: string;
valueClassName?: string;
}) {
return (
<div className="flex items-center gap-2">
{icon && <div className="text-muted-foreground">{icon}</div>}
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn('text-sm font-semibold', valueClassName)}>{value}</p>
</div>
</div>
);
}
function InfrastructureTab({ park }: { park: IndustrialParkDetail }) {
if (!park.infrastructure || Object.keys(park.infrastructure).length === 0) {
return <p className="text-sm text-muted-foreground">Chưa cập nhật thông tin hạ tầng.</p>;
}
return (
<div className="grid gap-3 sm:grid-cols-2">
{Object.entries(park.infrastructure).map(([key, value]) => (
<div key={key} className="rounded-lg border p-3">
<p className="text-xs font-medium text-muted-foreground capitalize">{key.replace(/_/g, ' ')}</p>
<p className="mt-1 text-sm">{value}</p>
</div>
))}
</div>
);
}
function ConnectivityTab({ park }: { park: IndustrialParkDetail }) {
if (!park.connectivity || Object.keys(park.connectivity).length === 0) {
return <p className="text-sm text-muted-foreground">Chưa cập nhật thông tin kết nối giao thông.</p>;
}
return (
<div className="space-y-2">
{Object.entries(park.connectivity).map(([key, info]) => (
<div key={key} className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-xs font-medium text-muted-foreground capitalize">{key.replace(/_/g, ' ')}</p>
<p className="text-sm font-medium">{info.name}</p>
</div>
<Badge variant="secondary">{info.distanceKm} km</Badge>
</div>
))}
</div>
);
}
function IncentivesTab({ park }: { park: IndustrialParkDetail }) {
if (!park.incentives || Object.keys(park.incentives).length === 0) {
return <p className="text-sm text-muted-foreground">Chưa cập nhật thông tin ưu đãi.</p>;
}
return (
<div className="space-y-3">
{Object.entries(park.incentives).map(([key, value]) => (
<div key={key} className="rounded-lg border p-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-500" />
<p className="text-sm font-medium capitalize">{key.replace(/_/g, ' ')}</p>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{typeof value === 'string' ? value : JSON.stringify(value)}
</p>
</div>
))}
</div>
);
}
function TenantsTab({ park }: { park: IndustrialParkDetail }) {
if (!park.existingTenants || park.existingTenants.length === 0) {
return <p className="text-sm text-muted-foreground">Chưa cập nhật danh sách doanh nghiệp.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="px-3 py-2 text-left font-medium">Doanh nghiệp</th>
<th className="px-3 py-2 text-left font-medium">Quốc gia</th>
<th className="px-3 py-2 text-left font-medium">Ngành nghề</th>
</tr>
</thead>
<tbody>
{park.existingTenants.map((tenant) => (
<tr key={tenant.name} className="border-b">
<td className="px-3 py-2 font-medium">{tenant.name}</td>
<td className="px-3 py-2">{tenant.country}</td>
<td className="px-3 py-2 text-muted-foreground">{tenant.industry}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function DocumentsTab({ park }: { park: IndustrialParkDetail }) {
if (!park.documents || park.documents.length === 0) {
return <p className="text-sm text-muted-foreground">Chưa tài liệu nào.</p>;
}
return (
<div className="space-y-2">
{park.documents.map((doc) => (
<a
key={doc.url}
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-accent"
>
<FileText className="h-5 w-5 shrink-0 text-primary" />
<p className="min-w-0 flex-1 truncate text-sm font-medium">{doc.name}</p>
<Download className="h-4 w-4 text-muted-foreground" />
</a>
))}
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { Building2, MapPin } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import { type IndustrialParkListItem, PARK_STATUS_COLORS, PARK_STATUS_LABELS, REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
interface ParkCardProps {
park: IndustrialParkListItem;
}
export function ParkCard({ park }: ParkCardProps) {
const occupancyColor =
park.occupancyRate >= 90 ? 'text-red-600' :
park.occupancyRate >= 70 ? 'text-amber-600' :
'text-green-600';
return (
<Link href={`/khu-cong-nghiep/${park.slug}`}>
<Card className="group h-full transition-shadow hover:shadow-lg">
<CardContent className="p-5">
{/* Header */}
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="line-clamp-1 font-semibold text-foreground group-hover:text-primary">
{park.name}
</h3>
{park.nameEn && (
<p className="line-clamp-1 text-xs text-muted-foreground">{park.nameEn}</p>
)}
</div>
<Badge className={PARK_STATUS_COLORS[park.status]} variant="secondary">
{PARK_STATUS_LABELS[park.status]}
</Badge>
</div>
{/* Location */}
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="line-clamp-1">{park.province} &middot; {REGION_LABELS[park.region]}</span>
</div>
{/* Stats grid */}
<div className="mb-3 grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted p-2">
<div className="text-xs text-muted-foreground">Diện tích</div>
<div className="font-semibold">{park.totalAreaHa.toLocaleString()} ha</div>
</div>
<div className="rounded-md bg-muted p-2">
<div className="text-xs text-muted-foreground">Lấp đy</div>
<div className={`font-semibold ${occupancyColor}`}>{park.occupancyRate}%</div>
</div>
<div className="rounded-md bg-muted p-2">
<div className="text-xs text-muted-foreground">Còn trống</div>
<div className="font-semibold">{park.remainingAreaHa.toLocaleString()} ha</div>
</div>
<div className="rounded-md bg-muted p-2">
<div className="text-xs text-muted-foreground">Doanh nghiệp</div>
<div className="font-semibold">{park.tenantCount}</div>
</div>
</div>
{/* Rent info */}
{park.landRentUsdM2Year && (
<div className="mb-3 flex items-center gap-4 text-sm">
<div>
<span className="text-muted-foreground">Thuê đt: </span>
<span className="font-medium text-primary">${park.landRentUsdM2Year}/m²/năm</span>
</div>
{park.rbfRentUsdM2Month && (
<div>
<span className="text-muted-foreground">NX: </span>
<span className="font-medium">${park.rbfRentUsdM2Month}/m²/th</span>
</div>
)}
</div>
)}
{/* Industries */}
<div className="flex flex-wrap gap-1">
{park.targetIndustries.slice(0, 3).map((industry) => (
<Badge key={industry} variant="outline" className="text-xs">
{industry}
</Badge>
))}
{park.targetIndustries.length > 3 && (
<Badge variant="outline" className="text-xs">
+{park.targetIndustries.length - 3}
</Badge>
)}
</div>
{/* Footer */}
<div className="mt-3 flex items-center gap-3 border-t pt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Building2 className="h-3 w-3" />
{park.developer}
</div>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { Search, X } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { type IndustrialParkStatus, type SearchIndustrialParksParams, type VietnamRegion, PARK_STATUS_LABELS, REGION_LABELS } from '@/lib/khu-cong-nghiep-api';
interface ParkFilterBarProps {
params: SearchIndustrialParksParams;
onChange: (params: SearchIndustrialParksParams) => void;
}
const PROVINCES = [
'Bắc Ninh', 'Bình Dương', 'Đồng Nai', 'Hà Nội', 'Hải Phòng', 'Hưng Yên',
'Long An', 'Bà Rịa - Vũng Tàu', 'Bình Phước', 'Hải Dương', 'Nghệ An',
'Quảng Nam', 'TP. Hồ Chí Minh',
];
export function ParkFilterBar({ params, onChange }: ParkFilterBarProps) {
const [searchInput, setSearchInput] = React.useState(params.q ?? '');
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
onChange({ ...params, q: searchInput.trim() || undefined, page: 1 });
};
const updateFilter = (key: keyof SearchIndustrialParksParams, value: string) => {
onChange({ ...params, [key]: value || undefined, page: 1 });
};
const handleClear = () => {
setSearchInput('');
onChange({ page: 1, limit: params.limit });
};
const hasFilters = params.q || params.region || params.province || params.status;
return (
<div className="space-y-3">
{/* Search bar */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Tìm kiếm KCN theo tên, chủ đầu tư, tỉnh..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" size="sm">Tìm</Button>
</form>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<select
value={params.region ?? ''}
onChange={(e) => updateFilter('region', e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Vùng miền"
>
<option value="">Vùng miền</option>
{(Object.entries(REGION_LABELS) as [VietnamRegion, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={params.province ?? ''}
onChange={(e) => updateFilter('province', e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Tỉnh/TP"
>
<option value="">Tỉnh/TP</option>
{PROVINCES.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<select
value={params.status ?? ''}
onChange={(e) => updateFilter('status', e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Trạng thái"
>
<option value="">Trạng thái</option>
{(Object.entries(PARK_STATUS_LABELS) as [IndustrialParkStatus, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={handleClear} className="gap-1">
<X className="h-3.5 w-3.5" />
Xóa bộ lọc
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { Calendar, Trash2, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import type { Report } from '@/lib/reports-api';
import { ReportStatusBadge } from './report-status-badge';
import { ReportTypeBadge } from './report-type-badge';
interface ReportCardProps {
report: Report;
onDelete?: (id: string) => void;
}
export function ReportCard({ report, onDelete }: ReportCardProps) {
const date = new Date(report.createdAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return (
<div className="group rounded-lg border bg-card p-4 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<ReportTypeBadge type={report.type} />
<ReportStatusBadge status={report.status} />
</div>
<h3 className="line-clamp-1 text-sm font-semibold">{report.title}</h3>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{date}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{report.status === 'READY' && (
<Link href={`/dashboard/reports/${report.id}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => onDelete(report.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
{report.status === 'READY' && (
<Link
href={`/dashboard/reports/${report.id}`}
className="mt-3 block text-xs font-medium text-primary hover:underline"
>
Xem báo cáo
</Link>
)}
{report.status === 'FAILED' && report.errorMsg && (
<p className="mt-2 text-xs text-destructive">{report.errorMsg}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import * as React from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface DataPoint {
period: string;
value: number;
unit: string;
}
interface ReportChartProps {
data: DataPoint[];
title: string;
variant?: 'area' | 'bar';
color?: string;
height?: number;
}
const COLORS = {
primary: '#2563eb',
secondary: '#16a34a',
accent: '#d97706',
};
function formatValue(value: number, unit: string): string {
if (unit === 'tỷ USD' || unit === 'tỷ VND') {
return `${value.toLocaleString('vi-VN')} ${unit}`;
}
if (unit === '%') {
return `${value}%`;
}
if (unit === 'người' || unit === 'triệu người') {
return value.toLocaleString('vi-VN');
}
return `${value.toLocaleString('vi-VN')} ${unit}`;
}
export function ReportChart({
data,
title,
variant = 'area',
color = COLORS.primary,
height = 240,
}: ReportChartProps) {
if (!data || data.length === 0) return null;
const unit = data[0]?.unit ?? '';
const chartData = data.map((d) => ({
name: d.period,
value: d.value,
}));
return (
<div className="rounded-lg border bg-card p-4">
<h4 className="mb-3 text-sm font-medium text-muted-foreground">{title}</h4>
<ResponsiveContainer width="100%" height={height}>
{variant === 'bar' ? (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number) => [formatValue(value, unit), title]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number) => [formatValue(value, unit), title]}
contentStyle={{ fontSize: 12 }}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
fill={color}
fillOpacity={0.1}
strokeWidth={2}
/>
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
}
interface ReportChartsGridProps {
charts: Record<string, DataPoint[]>;
labels?: Record<string, string>;
}
const DEFAULT_CHART_LABELS: Record<string, string> = {
gdp_trend: 'GDP',
fdi_trend: 'Vốn FDI',
population: 'Dân số',
urbanization: 'Đô thị hóa',
labor_force: 'Lực lượng lao động',
avg_wage: 'Lương bình quân',
industrial_output: 'Sản lượng công nghiệp',
cpi: 'Chỉ số giá tiêu dùng',
mortgage_rate: 'Lãi suất vay',
};
const CHART_COLORS: Record<string, string> = {
gdp_trend: COLORS.primary,
fdi_trend: COLORS.secondary,
population: COLORS.accent,
urbanization: COLORS.primary,
labor_force: COLORS.secondary,
avg_wage: COLORS.accent,
};
export function ReportChartsGrid({ charts, labels }: ReportChartsGridProps) {
const mergedLabels = { ...DEFAULT_CHART_LABELS, ...labels };
const validCharts = Object.entries(charts).filter(
([, data]) => Array.isArray(data) && data.length > 0,
);
if (validCharts.length === 0) return null;
return (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{validCharts.map(([key, data]) => (
<ReportChart
key={key}
data={data as DataPoint[]}
title={mergedLabels[key] ?? key}
color={CHART_COLORS[key] ?? COLORS.primary}
variant={key.includes('trend') ? 'area' : 'bar'}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
import type { ReportStatus } from '@/lib/reports-api';
const statusConfig: Record<ReportStatus, { label: string; icon: typeof CheckCircle; className: string }> = {
GENERATING: { label: 'Đang tạo...', icon: Loader2, className: 'text-blue-600 bg-blue-50' },
READY: { label: 'Hoàn thành', icon: CheckCircle, className: 'text-green-600 bg-green-50' },
FAILED: { label: 'Lỗi', icon: XCircle, className: 'text-red-600 bg-red-50' },
};
export function ReportStatusBadge({ status }: { status: ReportStatus }) {
const config = statusConfig[status];
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${config.className}`}>
<Icon className={`h-3 w-3 ${status === 'GENERATING' ? 'animate-spin' : ''}`} />
{config.label}
</span>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Building2, Factory, MapPin, TrendingUp, Warehouse, Calculator, Briefcase } from 'lucide-react';
import type { ReportType } from '@/lib/reports-api';
const typeConfig: Record<ReportType, { label: string; icon: typeof Building2; className: string }> = {
RESIDENTIAL_MARKET: { label: 'Nhà ở', icon: Building2, className: 'text-emerald-700 bg-emerald-50' },
INDUSTRIAL_MARKET: { label: 'KCN', icon: Factory, className: 'text-orange-700 bg-orange-50' },
DISTRICT_ANALYSIS: { label: 'Quận/Huyện', icon: MapPin, className: 'text-purple-700 bg-purple-50' },
INVESTMENT_FEASIBILITY: { label: 'Đầu tư', icon: TrendingUp, className: 'text-blue-700 bg-blue-50' },
INDUSTRIAL_LOCATION: { label: 'Vị trí KCN', icon: Warehouse, className: 'text-amber-700 bg-amber-50' },
PROPERTY_VALUATION: { label: 'Định giá', icon: Calculator, className: 'text-teal-700 bg-teal-50' },
PORTFOLIO: { label: 'Danh mục', icon: Briefcase, className: 'text-indigo-700 bg-indigo-50' },
};
export function ReportTypeBadge({ type }: { type: ReportType }) {
const config = typeConfig[type];
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${config.className}`}>
<Icon className="h-3 w-3" />
{config.label}
</span>
);
}
export function getReportTypeLabel(type: ReportType): string {
return typeConfig[type]?.label ?? type;
}
export const REPORT_TYPES = Object.entries(typeConfig).map(([value, config]) => ({
value: value as ReportType,
label: config.label,
icon: config.icon,
}));