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:
41
apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx
Normal file
41
apps/web/app/[locale]/(public)/chuyen-nhuong/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
226
apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx
Normal file
226
apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx
Normal 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ị và 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
117
apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx
Normal file
117
apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx
Normal 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 và 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>
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user