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'),