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,195 @@
'use client';
import { ArrowLeft, Download, Loader2, Printer } from 'lucide-react';
import { useParams } from 'next/navigation';
import * as React from 'react';
import { ReportChartsGrid } from '@/components/reports/report-chart';
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
import { ReportTypeBadge } from '@/components/reports/report-type-badge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { useReport, useReportStatus } from '@/lib/hooks/use-reports';
interface ReportSection {
title: string;
content?: string;
data?: unknown;
charts?: Record<string, unknown>;
projects?: unknown[];
summary?: unknown;
}
export default function ReportViewerPage() {
const routeParams = useParams();
const reportId = routeParams['id'] as string;
const { data: report, isLoading, refetch } = useReport(reportId);
const shouldPoll = report?.status === 'GENERATING';
const { data: statusData } = useReportStatus(reportId, shouldPoll);
// Refresh full report when status transitions to READY
React.useEffect(() => {
if (statusData?.status === 'READY' && report?.status === 'GENERATING') {
refetch();
}
}, [statusData?.status, report?.status, refetch]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!report) {
return (
<div className="py-20 text-center">
<p className="text-muted-foreground">Không tìm thấy báo cáo.</p>
</div>
);
}
const sections = (report.content as { sections?: Record<string, ReportSection> })?.sections;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Link href="/dashboard/reports">
<Button variant="ghost" size="sm" className="mt-1">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<ReportTypeBadge type={report.type} />
<ReportStatusBadge status={report.status} />
</div>
<h1 className="mt-1 text-xl font-bold">{report.title}</h1>
<p className="text-xs text-muted-foreground">
Tạo lúc {new Date(report.createdAt).toLocaleString('vi-VN')}
</p>
</div>
</div>
{report.status === 'READY' && (
<div className="flex items-center gap-2">
{report.pdfUrl && (
<a href={report.pdfUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
PDF
</Button>
</a>
)}
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="mr-1 h-4 w-4" />
In
</Button>
</div>
)}
</div>
{/* Generating state */}
{report.status === 'GENERATING' && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
<Loader2 className="mb-4 h-10 w-10 animate-spin text-primary" />
<h3 className="text-lg font-medium">Đang tạo báo cáo...</h3>
<p className="mt-1 text-sm text-muted-foreground">
Quá trình này thể mất vài phút. Trang sẽ tự cập nhật khi hoàn thành.
</p>
</div>
)}
{/* Failed state */}
{report.status === 'FAILED' && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6">
<h3 className="font-medium text-destructive">Tạo báo cáo thất bại</h3>
{report.errorMsg && (
<p className="mt-1 text-sm text-destructive/80">{report.errorMsg}</p>
)}
</div>
)}
{/* Report content */}
{report.status === 'READY' && sections && (
<div className="grid gap-4 lg:grid-cols-[200px_1fr]">
{/* Sidebar TOC */}
<nav className="hidden lg:block">
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Mục lục</p>
<ul className="space-y-1">
{Object.entries(sections).map(([key, section]) => (
<li key={key}>
<a
href={`#section-${key}`}
className="block rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{section.title}
</a>
</li>
))}
</ul>
</nav>
{/* Content sections */}
<div className="space-y-4">
{Object.entries(sections).map(([key, section]) => (
<section
key={key}
id={`section-${key}`}
className="rounded-lg border p-6"
>
<h2 className="mb-3 text-lg font-semibold">{section.title}</h2>
{section.content && (
<div className="space-y-2 text-sm leading-relaxed text-muted-foreground whitespace-pre-line">
{String(section.content)}
</div>
)}
{section.charts && (
<ReportChartsGrid charts={section.charts as Record<string, Array<{ period: string; value: number; unit: string }>>} />
)}
{section.data != null && !section.charts && (
<ReportChartsGrid charts={section.data as Record<string, Array<{ period: string; value: number; unit: string }>>} />
)}
{section.projects && Array.isArray(section.projects) && section.projects.length > 0 && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="pb-2 pr-4">Tên</th>
<th className="pb-2 pr-4">Loại</th>
<th className="pb-2 pr-4">Trạng thái</th>
</tr>
</thead>
<tbody>
{section.projects.map((proj: unknown, i: number) => {
const p = proj as Record<string, unknown>;
return (
<tr key={i} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{String(p['name'] ?? '')}</td>
<td className="py-2 pr-4">{String(p['category'] ?? '')}</td>
<td className="py-2 pr-4">{String(p['status'] ?? '')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
))}
</div>
</div>
)}
{/* Raw JSON fallback for content without sections */}
{report.status === 'READY' && !sections && report.content && (
<pre className="max-h-[600px] overflow-auto rounded-lg border bg-muted p-4 text-xs">
{JSON.stringify(report.content, null, 2)}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import { ArrowLeft, ArrowRight, Loader2, Sparkles } from 'lucide-react';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { useGenerateReport } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
type Step = 'type' | 'params' | 'confirm';
const PARAM_FIELDS: Record<ReportType, Array<{ key: string; label: string; placeholder: string; required: boolean }>> = {
RESIDENTIAL_MARKET: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
{ key: 'period', label: 'Kỳ báo cáo', placeholder: '2026-Q1', required: true },
],
INDUSTRIAL_MARKET: [
{ key: 'province', label: 'Tỉnh/TP', placeholder: 'Bình Dương', required: true },
],
DISTRICT_ANALYSIS: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
{ key: 'district', label: 'Quận/Huyện', placeholder: 'Quận 2', required: true },
],
INVESTMENT_FEASIBILITY: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
{ key: 'propertyType', label: 'Loại BĐS', placeholder: 'APARTMENT', required: true },
],
INDUSTRIAL_LOCATION: [
{ key: 'province', label: 'Tỉnh', placeholder: 'Bình Dương', required: true },
],
PROPERTY_VALUATION: [
{ key: 'propertyId', label: 'ID Bất động sản', placeholder: 'clx...', required: true },
],
PORTFOLIO: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: false },
],
};
export default function NewReportPage() {
const router = useRouter();
const { mutateAsync: trigger, isPending: isMutating } = useGenerateReport();
const [step, setStep] = React.useState<Step>('type');
const [selectedType, setSelectedType] = React.useState<ReportType | null>(null);
const [title, setTitle] = React.useState('');
const [params, setParams] = React.useState<Record<string, string>>({});
const typeInfo = selectedType ? REPORT_TYPES.find((t) => t.value === selectedType) : null;
const paramFields = selectedType ? PARAM_FIELDS[selectedType] : [];
const handleSelectType = (type: ReportType) => {
setSelectedType(type);
setParams({});
const label = REPORT_TYPES.find((t) => t.value === type)?.label ?? type;
setTitle(`Báo cáo ${label}`);
setStep('params');
};
const handleSubmit = async () => {
if (!selectedType) return;
const result = await trigger({
type: selectedType,
title,
params,
});
if (result?.reportId) {
router.push(`/dashboard/reports/${result.reportId}`);
}
};
return (
<div className="mx-auto max-w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/dashboard/reports">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Tạo báo cáo mới</h1>
<p className="text-sm text-muted-foreground">
{step === 'type' && 'Bước 1: Chọn loại báo cáo'}
{step === 'params' && 'Bước 2: Nhập thông tin'}
{step === 'confirm' && 'Bước 3: Xác nhận'}
</p>
</div>
</div>
{/* Step 1: Select type */}
{step === 'type' && (
<div className="grid gap-3 sm:grid-cols-2">
{REPORT_TYPES.map((rt) => (
<button
key={rt.value}
type="button"
className="flex items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:border-primary hover:bg-primary/5"
onClick={() => handleSelectType(rt.value)}
>
<rt.icon className="h-8 w-8 text-primary" />
<div>
<p className="font-medium">{rt.label}</p>
<p className="text-xs text-muted-foreground">Báo cáo {rt.label.toLowerCase()}</p>
</div>
</button>
))}
</div>
)}
{/* Step 2: Parameters */}
{step === 'params' && selectedType && (
<div className="space-y-4 rounded-lg border p-6">
<div>
<label className="mb-1 block text-sm font-medium">Tiêu đ báo cáo</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{paramFields.map((field) => (
<div key={field.key}>
<label className="mb-1 block text-sm font-medium">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</label>
<input
type="text"
placeholder={field.placeholder}
value={params[field.key] ?? ''}
onChange={(e) => setParams({ ...params, [field.key]: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
))}
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={() => setStep('type')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
<Button onClick={() => setStep('confirm')}>
Tiếp theo
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 'confirm' && selectedType && (
<div className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">Xác nhận tạo báo cáo</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Loại báo cáo:</span>
<span className="font-medium">{typeInfo?.label}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tiêu đ:</span>
<span className="font-medium">{title}</span>
</div>
{Object.entries(params)
.filter(([, v]) => v)
.map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-muted-foreground">{key}:</span>
<span className="font-medium">{value}</span>
</div>
))}
</div>
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={() => setStep('params')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
<Button onClick={handleSubmit} disabled={isMutating}>
{isMutating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Đang tạo...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Tạo báo cáo
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { FileText, Plus } from 'lucide-react';
import * as React from 'react';
import { ReportCard } from '@/components/reports/report-card';
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { useDeleteReport, useReportsList } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
export default function ReportsHubPage() {
const [activeType, setActiveType] = React.useState<ReportType | undefined>();
const { data, isLoading } = useReportsList({ type: activeType, limit: 20 });
const { mutate: triggerDelete } = useDeleteReport();
const handleDelete = (id: string) => {
triggerDelete(id);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Báo cáo AI</h1>
<p className="text-sm text-muted-foreground">
Tạo quản báo cáo phân tích thị trường bất đng sản
</p>
</div>
<Link href="/dashboard/reports/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
{/* Type filter tabs */}
<div className="flex flex-wrap gap-2">
<Button
variant={activeType === undefined ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveType(undefined)}
>
Tất cả
</Button>
{REPORT_TYPES.map((rt) => (
<Button
key={rt.value}
variant={activeType === rt.value ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveType(rt.value)}
>
<rt.icon className="mr-1 h-3.5 w-3.5" />
{rt.label}
</Button>
))}
</div>
{/* Reports list */}
{isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2">Đang tải...</span>
</div>
)}
{!isLoading && data && data.data.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((report) => (
<ReportCard key={report.id} report={report} onDelete={handleDelete} />
))}
</div>
)}
{!isLoading && data && data.data.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<FileText className="mb-4 h-12 w-12 text-muted-foreground/50" />
<h3 className="text-lg font-medium">Chưa báo cáo nào</h3>
<p className="mt-1 text-sm text-muted-foreground">
Tạo báo cáo AI đu tiên đ phân tích thị trường bất đng sản.
</p>
<Link href="/dashboard/reports/new" className="mt-4">
<Button>
<Plus className="mr-2 h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
)}
{data && data.total > 0 && (
<p className="text-xs text-muted-foreground">
Hiển thị {data.data.length} / {data.total} báo cáo
</p>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import {
Bookmark,
Bot,
CreditCard,
FileText,
Gem,
Home,
List,
@@ -68,6 +69,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
label: t('dashboard.analytics'),
items: [
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
],
@@ -93,6 +95,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
];
const secondaryNav: NavItem[] = [
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },

View File

@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ChuyenNhuongDetailClient } from '@/components/chuyen-nhuong/chuyen-nhuong-detail-client';
import { fetchTransferListingById } from '@/lib/chuyen-nhuong-server';
interface PageProps {
params: Promise<{ id: string; locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const listing = await fetchTransferListingById(id);
if (!listing) return { title: 'Không tìm thấy tin chuyển nhượng' };
const description = listing.description?.slice(0, 160) ??
`${listing.title} — Chuyển nhượng tại ${listing.district}, ${listing.city}`;
return {
title: `${listing.title} — Chuyển nhượng ${listing.district}`,
description,
openGraph: {
title: listing.title,
description,
images: listing.media
?.filter((m) => m.type === 'image')
.slice(0, 1)
.map((m) => ({ url: m.url })) ?? [],
},
};
}
export default async function ChuyenNhuongDetailPage({ params }: PageProps) {
const { id } = await params;
const listing = await fetchTransferListingById(id);
if (!listing) {
notFound();
}
return <ChuyenNhuongDetailClient listing={listing} />;
}

View File

@@ -0,0 +1,226 @@
'use client';
import { Package, Search, X } from 'lucide-react';
import * as React from 'react';
import { TransferListingCard } from '@/components/chuyen-nhuong/transfer-listing-card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
type SearchTransferListingsParams,
type TransferCategory,
type TransferListingStatus,
CATEGORY_ICONS,
CATEGORY_LABELS,
STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api';
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
const PAGE_SIZE = 12;
const DISTRICTS = [
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
];
export default function ChuyenNhuongPage() {
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
page: 1,
limit: PAGE_SIZE,
});
const [searchInput, setSearchInput] = React.useState('');
const { data, isLoading, isError } = useTransferListingsSearch(filters);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setFilters((prev) => ({ ...prev, q: searchInput.trim() || undefined, page: 1 }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCategoryChange = (category: TransferCategory | undefined) => {
setFilters((prev) => ({ ...prev, category, page: 1 }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const updateFilter = (key: keyof SearchTransferListingsParams, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value || undefined, page: 1 }));
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleClear = () => {
setSearchInput('');
setFilters({ page: 1, limit: PAGE_SIZE });
};
const hasFilters = filters.q || filters.category || filters.status || filters.district;
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold md:text-3xl">Chuyển Nhượng</h1>
<p className="mt-1 text-muted-foreground">
Tìm kiếm nội thất, thiết bị mặt bằng chuyển nhượng
</p>
</div>
{/* Search bar */}
<div className="space-y-3">
<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 theo tên, quận, loại hì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={filters.district ?? ''}
onChange={(e) => updateFilter('district', e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Quận/Huyện"
>
<option value="">Quận/Huyện</option>
{DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<select
value={filters.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(STATUS_LABELS) as [TransferListingStatus, 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>
{/* Category tabs */}
<div className="mt-4 flex gap-1 overflow-x-auto border-b" role="tablist">
<button
role="tab"
aria-selected={!filters.category}
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
!filters.category
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleCategoryChange(undefined)}
>
Tất cả
</button>
{(Object.entries(CATEGORY_LABELS) as [TransferCategory, string][]).map(([key, label]) => (
<button
key={key}
role="tab"
aria-selected={filters.category === key}
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
filters.category === key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleCategoryChange(key)}
>
{CATEGORY_ICONS[key]} {label}
</button>
))}
</div>
{/* Results */}
<div className="mt-6">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-72 animate-pulse rounded-lg bg-muted"
/>
))}
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Không thể tải danh sách chuyển nhượng. Vui lòng thử lại.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setFilters({ ...filters })}
>
Thử lại
</Button>
</div>
) : data && data.data.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
{data.total} tin chuyển nhượng đưc tìm thấy
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((listing) => (
<TransferListingCard key={listing.id} listing={listing} />
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange((filters.page || 1) - 1)}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {data.page} / {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() => handlePageChange((filters.page || 1) + 1)}
>
Sau
</Button>
</div>
)}
</>
) : (
<div className="py-12 text-center">
<Package className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Không tìm thấy tin chuyển nhượng</p>
<p className="mt-1 text-sm text-muted-foreground">
Thử thay đi bộ lọc đ tìm kiếm nhiều hơn
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { KhuCongNghiepDetailClient } from '@/components/khu-cong-nghiep/khu-cong-nghiep-detail-client';
import { fetchIndustrialParkBySlug } from '@/lib/khu-cong-nghiep-server';
interface PageProps {
params: Promise<{ slug: string; locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const park = await fetchIndustrialParkBySlug(slug);
if (!park) return { title: 'Không tìm thấy khu công nghiệp' };
const description = park.description?.slice(0, 160) ??
`${park.name} — KCN tại ${park.province}, diện tích ${park.totalAreaHa} ha, tỷ lệ lấp đầy ${park.occupancyRate}%`;
return {
title: `${park.name} — Khu Công Nghiệp ${park.province}`,
description,
openGraph: {
title: park.name,
description,
images: park.media
?.filter((m) => m.type === 'image')
.slice(0, 1)
.map((m) => ({ url: m.url })) ?? [],
},
};
}
export default async function KhuCongNghiepDetailPage({ params }: PageProps) {
const { slug } = await params;
const park = await fetchIndustrialParkBySlug(slug);
if (!park) {
notFound();
}
return <KhuCongNghiepDetailClient park={park} />;
}

View File

@@ -0,0 +1,117 @@
'use client';
import { Factory } from 'lucide-react';
import * as React from 'react';
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
import { Button } from '@/components/ui/button';
import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep';
import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api';
const PAGE_SIZE = 12;
export default function KhuCongNghiepPage() {
const [filters, setFilters] = React.useState<SearchIndustrialParksParams>({
page: 1,
limit: PAGE_SIZE,
});
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
const handleFilterChange = (newFilters: SearchIndustrialParksParams) => {
setFilters({ ...newFilters, limit: PAGE_SIZE });
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold md:text-3xl">Khu Công Nghiệp Việt Nam</h1>
<p className="mt-1 text-muted-foreground">
Tìm kiếm so sánh các khu công nghiệp trên toàn quốc
</p>
</div>
{/* Filters */}
<ParkFilterBar params={filters} onChange={handleFilterChange} />
{/* Results */}
<div className="mt-6">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-72 animate-pulse rounded-lg bg-muted"
/>
))}
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Không thể tải danh sách khu công nghiệp. Vui lòng thử lại.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setFilters({ ...filters })}
>
Thử lại
</Button>
</div>
) : data && data.data.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
{data.total} khu công nghiệp đưc tìm thấy
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange((filters.page || 1) - 1)}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {data.page} / {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() => handlePageChange((filters.page || 1) + 1)}
>
Sau
</Button>
</div>
)}
</>
) : (
<div className="py-12 text-center">
<Factory className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Không tìm thấy khu công nghiệp</p>
<p className="mt-1 text-sm text-muted-foreground">
Thử thay đi bộ lọc đ tìm kiếm nhiều hơn
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -34,6 +34,16 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
label: t('nav.projects'),
isActive: pathname.includes('/du-an'),
},
{
href: '/khu-cong-nghiep' as const,
label: t('nav.industrialParks'),
isActive: pathname.includes('/khu-cong-nghiep'),
},
{
href: '/chuyen-nhuong' as const,
label: t('nav.transfer'),
isActive: pathname.includes('/chuyen-nhuong'),
},
{
href: '/pricing' as const,
label: t('nav.pricing'),

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,
}));

View File

@@ -0,0 +1,179 @@
import { apiClient } from './api-client';
// ─── Types ──────────────────────────────────────────────
export type TransferCategory = 'FURNITURE' | 'APPLIANCE' | 'OFFICE_EQUIPMENT' | 'KITCHEN' | 'PREMISES' | 'FULL_UNIT';
export type TransferCondition = 'NEW' | 'LIKE_NEW' | 'GOOD' | 'FAIR' | 'WORN';
export type TransferListingStatus = 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | 'RESERVED' | 'SOLD' | 'EXPIRED' | 'REJECTED';
export type TransferPricingSource = 'MANUAL' | 'AI_ESTIMATED' | 'NEGOTIABLE';
export interface TransferListingListItem {
id: string;
sellerId: string;
category: TransferCategory;
status: TransferListingStatus;
title: string;
address: string;
district: string;
city: string;
latitude: number;
longitude: number;
askingPriceVND: string; // BigInt serialized as string
aiEstimatePriceVND: string | null;
pricingSource: TransferPricingSource;
isNegotiable: boolean;
areaM2: number | null;
media: { url: string; type: string; order: number; caption?: string }[] | null;
viewCount: number;
inquiryCount: number;
publishedAt: string | null;
itemCount: number;
}
export interface TransferItemData {
id: string;
name: string;
brand: string | null;
modelName: string | null;
category: TransferCategory;
condition: TransferCondition;
purchaseYear: number | null;
originalPriceVND: string | null;
askingPriceVND: string;
aiEstimatePriceVND: string | null;
aiConfidence: number | null;
quantity: number;
dimensions: { widthCm?: number; heightCm?: number; depthCm?: number; weightKg?: number } | null;
media: { url: string; type: string; order: number }[] | null;
notes: string | null;
}
export interface TransferListingDetail {
id: string;
sellerId: string;
category: TransferCategory;
status: TransferListingStatus;
title: string;
description: string | null;
address: string;
ward: string | null;
district: string;
city: string;
latitude: number;
longitude: number;
askingPriceVND: string;
aiEstimatePriceVND: string | null;
aiConfidence: number | null;
pricingSource: TransferPricingSource;
isNegotiable: boolean;
areaM2: number | null;
monthlyRentVND: string | null;
depositMonths: number | null;
remainingLeaseMo: number | null;
businessType: string | null;
footTraffic: string | null;
media: { url: string; type: string; order: number; caption?: string }[] | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
contactPhone: string | null;
contactName: string | null;
items: TransferItemData[];
createdAt: string;
updatedAt: string;
}
export interface TransferStats {
totalListings: number;
totalValue: string;
byCategory: { category: string; count: number; avgPrice: number }[];
byDistrict: { district: string; count: number; avgPrice: number }[];
byStatus: { status: string; count: number }[];
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface SearchTransferListingsParams {
q?: string;
category?: TransferCategory;
status?: TransferListingStatus;
district?: string;
city?: string;
minPrice?: number;
maxPrice?: number;
page?: number;
limit?: number;
}
// ─── Labels ─────────────────────────────────────────────
export const CATEGORY_LABELS: Record<TransferCategory, string> = {
FURNITURE: 'Nội thất',
APPLIANCE: 'Thiết bị gia dụng',
OFFICE_EQUIPMENT: 'Thiết bị văn phòng',
KITCHEN: 'Bếp & thiết bị',
PREMISES: 'Mặt bằng',
FULL_UNIT: 'Trọn bộ',
};
export const CATEGORY_ICONS: Record<TransferCategory, string> = {
FURNITURE: '🛋️',
APPLIANCE: '🧊',
OFFICE_EQUIPMENT: '🖥️',
KITCHEN: '🍳',
PREMISES: '🏪',
FULL_UNIT: '🏠',
};
export const CONDITION_LABELS: Record<TransferCondition, string> = {
NEW: 'Mới',
LIKE_NEW: 'Như mới',
GOOD: 'Tốt',
FAIR: 'Khá',
WORN: 'Cũ',
};
export const CONDITION_COLORS: Record<TransferCondition, string> = {
NEW: 'bg-green-100 text-green-800',
LIKE_NEW: 'bg-emerald-100 text-emerald-800',
GOOD: 'bg-blue-100 text-blue-800',
FAIR: 'bg-amber-100 text-amber-800',
WORN: 'bg-red-100 text-red-800',
};
export const STATUS_LABELS: Record<TransferListingStatus, string> = {
DRAFT: 'Nháp',
PENDING_REVIEW: 'Chờ duyệt',
ACTIVE: 'Đang đăng',
RESERVED: 'Đã giữ',
SOLD: 'Đã bán',
EXPIRED: 'Hết hạn',
REJECTED: 'Từ chối',
};
// ─── API Functions ──────────────────────────────────────
export const transferApi = {
search: (params: SearchTransferListingsParams = {}) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.append(key, String(value));
});
const qs = query.toString();
return apiClient.get<PaginatedResult<TransferListingListItem>>(
`/transfer/listings${qs ? `?${qs}` : ''}`,
);
},
getById: (id: string) =>
apiClient.get<TransferListingDetail>(`/transfer/listings/${id}`),
getStats: () =>
apiClient.get<TransferStats>('/transfer/stats'),
};

View File

@@ -0,0 +1,15 @@
import type { TransferListingDetail } from './chuyen-nhuong-api';
const API_URL = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001/api/v1';
export async function fetchTransferListingById(id: string): Promise<TransferListingDetail | null> {
try {
const res = await fetch(`${API_URL}/transfer/listings/${id}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json() as Promise<TransferListingDetail>;
} catch {
return null;
}
}

View File

@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import { transferApi, type SearchTransferListingsParams } from '@/lib/chuyen-nhuong-api';
export const transferKeys = {
all: ['transfer'] as const,
search: (params: SearchTransferListingsParams) => ['transfer', 'search', params] as const,
detail: (id: string) => ['transfer', 'detail', id] as const,
stats: () => ['transfer', 'stats'] as const,
};
export function useTransferListingsSearch(params: SearchTransferListingsParams = {}) {
return useQuery({
queryKey: transferKeys.search(params),
queryFn: () => transferApi.search(params),
});
}
export function useTransferListingDetail(id: string) {
return useQuery({
queryKey: transferKeys.detail(id),
queryFn: () => transferApi.getById(id),
enabled: !!id,
});
}
export function useTransferStats() {
return useQuery({
queryKey: transferKeys.stats(),
queryFn: () => transferApi.getStats(),
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import {
industrialApi,
type SearchIndustrialParksParams,
} from '@/lib/khu-cong-nghiep-api';
export const industrialKeys = {
all: ['industrial'] as const,
search: (params: SearchIndustrialParksParams) => ['industrial', 'search', params] as const,
detail: (slug: string) => ['industrial', 'detail', slug] as const,
stats: () => ['industrial', 'stats'] as const,
market: () => ['industrial', 'market'] as const,
compare: (ids: string[]) => ['industrial', 'compare', ids] as const,
};
export function useIndustrialParksSearch(params: SearchIndustrialParksParams = {}) {
return useQuery({
queryKey: industrialKeys.search(params),
queryFn: () => industrialApi.search(params),
});
}
export function useIndustrialParkDetail(slug: string) {
return useQuery({
queryKey: industrialKeys.detail(slug),
queryFn: () => industrialApi.getBySlug(slug),
enabled: !!slug,
});
}
export function useIndustrialStats() {
return useQuery({
queryKey: industrialKeys.stats(),
queryFn: () => industrialApi.getStats(),
staleTime: 5 * 60 * 1000,
});
}
export function useIndustrialMarket() {
return useQuery({
queryKey: industrialKeys.market(),
queryFn: () => industrialApi.getMarket(),
staleTime: 5 * 60 * 1000,
});
}
export function useIndustrialCompare(ids: string[]) {
return useQuery({
queryKey: industrialKeys.compare(ids),
queryFn: () => industrialApi.compare(ids),
enabled: ids.length >= 2,
});
}

View File

@@ -0,0 +1,68 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listReports,
getReport,
getReportStatus,
generateReport,
deleteReport,
type ReportType,
} from '../reports-api';
export const reportKeys = {
all: ['reports'] as const,
list: (params?: { type?: ReportType; limit?: number; offset?: number }) =>
['reports', 'list', params] as const,
detail: (id: string) => ['reports', 'detail', id] as const,
status: (id: string) => ['reports', 'status', id] as const,
};
export function useReportsList(params?: {
type?: ReportType;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: reportKeys.list(params),
queryFn: () => listReports(params),
});
}
export function useReport(id: string | null) {
return useQuery({
queryKey: reportKeys.detail(id!),
queryFn: () => getReport(id!),
enabled: !!id,
});
}
export function useReportStatus(id: string | null, shouldPoll = false) {
return useQuery({
queryKey: reportKeys.status(id!),
queryFn: () => getReportStatus(id!),
enabled: !!id,
refetchInterval: shouldPoll ? 3000 : false,
});
}
export function useGenerateReport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { type: ReportType; title: string; params: Record<string, unknown> }) =>
generateReport(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reportKeys.all });
},
});
}
export function useDeleteReport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteReport(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reportKeys.all });
},
});
}

View File

@@ -0,0 +1,160 @@
import { apiClient } from './api-client';
// ─── Types ──────────────────────────────────────────────
export type IndustrialParkStatus =
| 'PLANNING'
| 'UNDER_CONSTRUCTION'
| 'OPERATIONAL'
| 'FULL';
export type VietnamRegion = 'NORTH' | 'CENTRAL' | 'SOUTH';
export interface IndustrialParkListItem {
id: string;
name: string;
nameEn: string | null;
slug: string;
developer: string;
status: IndustrialParkStatus;
province: string;
region: VietnamRegion;
totalAreaHa: number;
occupancyRate: number;
remainingAreaHa: number;
tenantCount: number;
landRentUsdM2Year: number | null;
rbfRentUsdM2Month: number | null;
rbwRentUsdM2Month: number | null;
targetIndustries: string[];
latitude: number;
longitude: number;
}
export interface IndustrialParkDetail {
id: string;
name: string;
nameEn: string | null;
slug: string;
developer: string;
operator: string | null;
status: IndustrialParkStatus;
latitude: number;
longitude: number;
address: string;
district: string;
province: string;
region: VietnamRegion;
totalAreaHa: number;
leasableAreaHa: number;
occupancyRate: number;
remainingAreaHa: number;
tenantCount: number;
establishedYear: number | null;
landRentUsdM2Year: number | null;
rbfRentUsdM2Month: number | null;
rbwRentUsdM2Month: number | null;
managementFeeUsd: number | null;
infrastructure: Record<string, string> | null;
connectivity: Record<string, { name: string; distanceKm: number }> | null;
incentives: Record<string, unknown> | null;
targetIndustries: string[];
existingTenants: { name: string; country: string; industry: string }[] | null;
certifications: string[] | null;
media: { url: string; type: string; caption?: string }[] | null;
documents: { url: string; name: string }[] | null;
description: string | null;
descriptionEn: string | null;
isVerified: boolean;
listingCount: number;
createdAt: string;
updatedAt: string;
}
export interface IndustrialParkStats {
totalParks: number;
totalAreaHa: number;
avgOccupancyRate: number;
totalTenants: number;
byRegion: { region: string; count: number; avgOccupancy: number }[];
byStatus: { status: string; count: number }[];
topProvinces: { province: string; count: number; avgRent: number | null }[];
}
export interface IndustrialMarketData {
totalParks: number;
avgOccupancyRate: number;
avgLandRentUsdM2: number | null;
avgRbfRentUsdM2: number | null;
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface SearchIndustrialParksParams {
q?: string;
province?: string;
region?: VietnamRegion;
status?: IndustrialParkStatus;
minAreaHa?: number;
maxRentUsdM2?: number;
targetIndustry?: string;
page?: number;
limit?: number;
}
// ─── Labels ─────────────────────────────────────────────
export const PARK_STATUS_LABELS: Record<IndustrialParkStatus, string> = {
PLANNING: 'Quy hoạch',
UNDER_CONSTRUCTION: 'Đang xây dựng',
OPERATIONAL: 'Đang hoạt động',
FULL: 'Đã lấp đầy',
};
export const PARK_STATUS_COLORS: Record<IndustrialParkStatus, string> = {
PLANNING: 'bg-blue-100 text-blue-800',
UNDER_CONSTRUCTION: 'bg-amber-100 text-amber-800',
OPERATIONAL: 'bg-green-100 text-green-800',
FULL: 'bg-red-100 text-red-800',
};
export const REGION_LABELS: Record<VietnamRegion, string> = {
NORTH: 'Miền Bắc',
CENTRAL: 'Miền Trung',
SOUTH: 'Miền Nam',
};
// ─── API Functions ──────────────────────────────────────
export const industrialApi = {
search: (params: SearchIndustrialParksParams = {}) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.append(key, String(value));
});
const qs = query.toString();
return apiClient.get<PaginatedResult<IndustrialParkListItem>>(
`/industrial/parks${qs ? `?${qs}` : ''}`,
);
},
getBySlug: (slug: string) =>
apiClient.get<IndustrialParkDetail>(`/industrial/parks/${slug}`),
compare: (ids: string[]) =>
apiClient.post<IndustrialParkDetail[]>('/industrial/parks/compare', { ids }),
getStats: () =>
apiClient.get<IndustrialParkStats>('/industrial/parks/stats'),
getMarket: () =>
apiClient.get<IndustrialMarketData>('/industrial/market'),
};

View File

@@ -0,0 +1,15 @@
import type { IndustrialParkDetail } from './khu-cong-nghiep-api';
const API_URL = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001/api/v1';
export async function fetchIndustrialParkBySlug(slug: string): Promise<IndustrialParkDetail | null> {
try {
const res = await fetch(`${API_URL}/industrial/parks/${slug}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json() as Promise<IndustrialParkDetail>;
} catch {
return null;
}
}

View File

@@ -0,0 +1,78 @@
import { apiClient } from './api-client';
// ─── Types ──────────────────────────────────────────────
export type ReportType =
| 'RESIDENTIAL_MARKET'
| 'INDUSTRIAL_MARKET'
| 'DISTRICT_ANALYSIS'
| 'INVESTMENT_FEASIBILITY'
| 'INDUSTRIAL_LOCATION'
| 'PROPERTY_VALUATION'
| 'PORTFOLIO';
export type ReportStatus = 'GENERATING' | 'READY' | 'FAILED';
export interface Report {
id: string;
type: ReportType;
title: string;
params: Record<string, unknown>;
content: Record<string, unknown> | null;
pdfUrl: string | null;
status: ReportStatus;
errorMsg: string | null;
createdAt: string;
updatedAt: string;
}
export interface ListReportsResponse {
data: Report[];
total: number;
}
export interface GenerateReportResponse {
reportId: string;
}
export interface ReportStatusResponse {
id: string;
status: ReportStatus;
errorMsg: string | null;
pdfUrl: string | null;
}
// ─── API Calls ──────────────────────────────────────────
export function listReports(params?: {
type?: ReportType;
limit?: number;
offset?: number;
}): Promise<ListReportsResponse> {
const searchParams = new URLSearchParams();
if (params?.type) searchParams.set('type', params.type);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return apiClient.get<ListReportsResponse>(`/reports${qs ? `?${qs}` : ''}`);
}
export function getReport(id: string): Promise<Report> {
return apiClient.get<Report>(`/reports/${id}`);
}
export function getReportStatus(id: string): Promise<ReportStatusResponse> {
return apiClient.get<ReportStatusResponse>(`/reports/${id}/status`);
}
export function generateReport(data: {
type: ReportType;
title: string;
params: Record<string, unknown>;
}): Promise<GenerateReportResponse> {
return apiClient.post<GenerateReportResponse>('/reports/generate', data);
}
export function deleteReport(id: string): Promise<void> {
return apiClient.delete<void>(`/reports/${id}`);
}

View File

@@ -27,6 +27,8 @@
"search": "Search",
"pricing": "Pricing",
"projects": "Projects",
"industrialParks": "Industrial Parks",
"transfer": "Transfer",
"mainNav": "Main navigation",
"dashboardNav": "Dashboard",
"adminNav": "Administration",
@@ -40,6 +42,7 @@
"inquiries": "Inquiries",
"leads": "Leads",
"analytics": "Analytics",
"reports": "AI Reports",
"savedSearches": "Saved searches",
"aiValuation": "AI Valuation",
"profile": "Profile",

View File

@@ -27,6 +27,8 @@
"search": "Tìm kiếm",
"pricing": "Bảng giá",
"projects": "Dự án",
"industrialParks": "Khu CN",
"transfer": "Chuyển nhượng",
"mainNav": "Điều hướng chính",
"dashboardNav": "Bảng điều khiển",
"adminNav": "Quản trị",
@@ -40,6 +42,7 @@
"inquiries": "Liên hệ",
"leads": "Lead",
"analytics": "Phân tích",
"reports": "Báo cáo AI",
"savedSearches": "Tìm kiếm đã lưu",
"aiValuation": "Định giá AI",
"profile": "Hồ sơ",

View File

@@ -43,7 +43,7 @@ const nextConfig = {
"style-src 'self' 'unsafe-inline' https://api.mapbox.com",
"img-src 'self' data: blob: https://*.mapbox.com https://*.tiles.mapbox.com https:",
"font-src 'self' data:",
`connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com https://api.goodgo.vn${process.env.NODE_ENV !== 'production' ? ' http://localhost:3001 http://localhost:3011' : ''}`,
`connect-src 'self' https://*.mapbox.com https://api.mapbox.com https://events.mapbox.com https://api.goodgo.vn${process.env.NODE_ENV !== 'production' ? ' http://localhost:3001 http://localhost:3011 http://localhost:9000' : ''}`,
"worker-src 'self' blob:",
"child-src 'self' blob:",
"frame-ancestors 'none'",