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:
@@ -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>Mô 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á có 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>
|
||||
);
|
||||
}
|
||||
77
apps/web/components/chuyen-nhuong/transfer-item-table.tsx
Normal file
77
apps/web/components/chuyen-nhuong/transfer-item-table.tsx
Normal 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 có 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>
|
||||
);
|
||||
}
|
||||
102
apps/web/components/chuyen-nhuong/transfer-listing-card.tsx
Normal file
102
apps/web/components/chuyen-nhuong/transfer-listing-card.tsx
Normal 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²</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>
|
||||
);
|
||||
}
|
||||
@@ -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 lý</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 lý</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Chủ đầu tư</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 có 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/khu-cong-nghiep/park-card.tsx
Normal file
105
apps/web/components/khu-cong-nghiep/park-card.tsx
Normal 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} · {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>
|
||||
);
|
||||
}
|
||||
102
apps/web/components/khu-cong-nghiep/park-filter-bar.tsx
Normal file
102
apps/web/components/khu-cong-nghiep/park-filter-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/web/components/reports/report-card.tsx
Normal file
74
apps/web/components/reports/report-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/web/components/reports/report-chart.tsx
Normal file
152
apps/web/components/reports/report-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/components/reports/report-status-badge.tsx
Normal file
22
apps/web/components/reports/report-status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/reports/report-type-badge.tsx
Normal file
36
apps/web/components/reports/report-type-badge.tsx
Normal 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,
|
||||
}));
|
||||
Reference in New Issue
Block a user