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