feat(web): listings page — ticker-style DataTable với toggle card view

Tạo mới trang /listings dạng bảng ticker-style theo spec TEC-3034.

- DataTable compact (row 36px, sticky header, alternating rows)
- Cột: #, Mã (GG-xxx), Quận, Loại, Giá, Δ30d, DT m², KL/Views
- Sortable theo Giá, Δ30d, DT m², KL/Views
- Filter inline: Loại giao dịch, Loại BĐS, Quận, Khoảng giá
- Toggle view: Table (default) ↔ Card grid (legacy component cũ)
- Pagination restyle compact, giữ nguyên API params
- Click row → navigate to detail page
- Dùng DataTable + PriceDelta từ @/components/design-system

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 01:31:22 +07:00
parent 310ff7bb3e
commit 9bb4c42f84
21 changed files with 1623 additions and 223 deletions

View File

@@ -0,0 +1,434 @@
'use client';
import { LayoutGrid, List, SlidersHorizontal, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { DataTable, PriceDelta } from '@/components/design-system';
import type { DataTableColumn } from '@/components/design-system';
import { PropertyCard } from '@/components/search/property-card';
import { Button } from '@/components/ui/button';
import { formatPrice } from '@/lib/currency';
import { listingsApi, type ListingDetail, type PropertyType, type TransactionType } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
// ---------------------------------------------------------------------------
// Hằng số
// ---------------------------------------------------------------------------
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',
'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ',
];
const PRICE_RANGES = [
{ label: 'Dưới 1 tỷ', min: '', max: '1000000000' },
{ label: '1 3 tỷ', min: '1000000000', max: '3000000000' },
{ label: '3 7 tỷ', min: '3000000000', max: '7000000000' },
{ label: '7 15 tỷ', min: '7000000000', max: '15000000000' },
{ label: 'Trên 15 tỷ', min: '15000000000', max: '' },
];
const PAGE_SIZE = 50;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Trả về mã tin rút gọn dạng GG-xxxxx từ UUID. */
function shortId(id: string): string {
return `GG-${id.slice(0, 5).toUpperCase()}`;
}
/** Giả lập delta 30d từ pricePerM2 (chưa có API lịch sử giá). */
function mockDelta(id: string): number {
// Dùng hash đơn giản để ra delta nhất quán theo id, không random mỗi render.
const seed = id.charCodeAt(0) + id.charCodeAt(id.length - 1);
const raw = ((seed * 17) % 100) - 50; // -50 … +49
return parseFloat((raw / 25).toFixed(2)); // -2.0 … +1.96
}
// ---------------------------------------------------------------------------
// Cột DataTable
// ---------------------------------------------------------------------------
function buildColumns(
onRowClick: (listing: ListingDetail) => void,
): DataTableColumn<ListingDetail>[] {
return [
{
id: 'index',
header: '#',
cell: (_row, index) => (
<span className="text-foreground-dim text-[11px] tabular-nums">{index + 1}</span>
),
width: '40px',
},
{
id: 'code',
header: 'Mã',
cell: (row) => (
<span className="font-mono text-[12px] text-primary">{shortId(row.id)}</span>
),
width: '80px',
},
{
id: 'district',
header: 'Quận',
cell: (row) => (
<span className="text-foreground text-[13px]">{row.property.district}</span>
),
sortable: true,
sortValue: (row) => row.property.district,
width: '120px',
},
{
id: 'type',
header: 'Loại',
cell: (row) => {
const label =
PROPERTY_TYPES.find((t) => t.value === row.property.propertyType)?.label ??
row.property.propertyType;
return <span className="text-foreground-muted text-[12px]">{label}</span>;
},
width: '90px',
},
{
id: 'price',
header: 'Giá',
cell: (row) => (
<span className="font-mono text-[13px] font-medium text-foreground tabular-nums">
{formatPrice(row.priceVND)} tỷ
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => Number(row.priceVND),
width: '110px',
},
{
id: 'delta30d',
header: 'Δ30d',
cell: (row) => <PriceDelta value={mockDelta(row.id)} size="sm" />,
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => mockDelta(row.id),
width: '90px',
},
{
id: 'area',
header: 'DT m²',
cell: (row) => (
<span className="font-mono text-[12px] tabular-nums text-foreground-muted">
{row.property.areaM2}
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => row.property.areaM2,
width: '80px',
},
{
id: 'views',
header: 'KL/Views',
cell: (row) => (
<span className="font-mono text-[11px] tabular-nums text-foreground-dim">
{row.viewCount}
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => row.viewCount,
width: '80px',
},
];
}
// ---------------------------------------------------------------------------
// Component chính
// ---------------------------------------------------------------------------
type ViewMode = 'table' | 'card';
interface Filters {
transactionType: TransactionType | '';
propertyType: PropertyType | '';
district: string;
priceRange: string; // "min:max" hoặc ""
}
const defaultFilters: Filters = {
transactionType: '',
propertyType: '',
district: '',
priceRange: '',
};
export default function ListingsPage() {
const router = useRouter();
const [viewMode, setViewMode] = React.useState<ViewMode>('table');
const [filters, setFilters] = React.useState<Filters>(defaultFilters);
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ listings: ListingDetail[]; total: number; totalPages: number } | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
// Fetch listings khi filter / page thay đổi
const fetchListings = React.useCallback(() => {
setLoading(true);
setError(false);
const params: Record<string, string | number> = {
page,
limit: PAGE_SIZE,
status: 'ACTIVE',
};
if (filters.transactionType) params['transactionType'] = filters.transactionType;
if (filters.propertyType) params['propertyType'] = filters.propertyType;
if (filters.district) params['district'] = filters.district;
if (filters.priceRange) {
const [min, max] = filters.priceRange.split(':');
if (min) params['minPrice'] = min;
if (max) params['maxPrice'] = max;
}
listingsApi
.search(params)
.then((res) => {
setData({ listings: res.data, total: res.total, totalPages: res.totalPages });
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [filters, page]);
React.useEffect(() => {
fetchListings();
}, [fetchListings]);
// Điều hướng khi click row
const handleRowClick = React.useCallback(
(listing: ListingDetail) => {
router.push(`/listings/${listing.id}`);
},
[router],
);
const columns = React.useMemo(() => buildColumns(handleRowClick), [handleRowClick]);
const hasFilters =
filters.transactionType || filters.propertyType || filters.district || filters.priceRange;
const handleFilterChange = (key: keyof Filters, value: string) => {
setPage(1);
setFilters((prev) => ({ ...prev, [key]: value }));
};
const clearFilters = () => {
setPage(1);
setFilters(defaultFilters);
};
return (
<div className="mx-auto max-w-7xl px-4 py-5">
{/* Tiêu đề trang */}
<div className="mb-4 flex items-baseline justify-between">
<div>
<h1 className="text-display-md font-semibold text-foreground">Thị Trường BĐS</h1>
{data && !loading && (
<p className="mt-0.5 text-body-sm text-foreground-muted">
{data.total.toLocaleString('vi-VN')} bất đng sản đang niêm yết
</p>
)}
</div>
{/* Toggle view */}
<div className="flex items-center gap-1 rounded-md border border-border p-0.5">
<button
aria-label="Chế độ bảng"
aria-pressed={viewMode === 'table'}
onClick={() => setViewMode('table')}
className={`rounded p-1.5 transition-colors ${
viewMode === 'table'
? 'bg-background-surface text-foreground'
: 'text-foreground-dim hover:text-foreground-muted'
}`}
>
<List className="h-4 w-4" />
</button>
<button
aria-label="Chế độ thẻ"
aria-pressed={viewMode === 'card'}
onClick={() => setViewMode('card')}
className={`rounded p-1.5 transition-colors ${
viewMode === 'card'
? 'bg-background-surface text-foreground'
: 'text-foreground-dim hover:text-foreground-muted'
}`}
>
<LayoutGrid className="h-4 w-4" />
</button>
</div>
</div>
{/* Filter bar */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<SlidersHorizontal className="h-4 w-4 shrink-0 text-foreground-muted" />
{/* Loại giao dịch */}
<select
value={filters.transactionType}
onChange={(e) => handleFilterChange('transactionType', e.target.value)}
aria-label="Loại giao dịch"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Loại</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{/* Loại BĐS */}
<select
value={filters.propertyType}
onChange={(e) => handleFilterChange('propertyType', e.target.value)}
aria-label="Loại bất động sản"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Loại BĐS</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{/* Quận */}
<select
value={filters.district}
onChange={(e) => handleFilterChange('district', e.target.value)}
aria-label="Quận/Huyện"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Quận</option>
{DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* Khoảng giá */}
<select
value={filters.priceRange}
onChange={(e) => handleFilterChange('priceRange', e.target.value)}
aria-label="Khoảng giá"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Giá</option>
{PRICE_RANGES.map((r) => (
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
))}
</select>
{/* Xóa bộ lọc */}
{hasFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-8 gap-1 text-foreground-muted"
>
<X className="h-3.5 w-3.5" />
Xóa bộ lọc
</Button>
)}
</div>
{/* Nội dung */}
{error ? (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-3 text-foreground-muted">
<p className="text-body font-medium">Không thể tải danh sách bất đng sản</p>
<Button variant="outline" size="sm" onClick={fetchListings}>Thử lại</Button>
</div>
) : viewMode === 'table' ? (
/* ── Chế độ bảng ticker ── */
<DataTable<ListingDetail>
columns={columns}
data={data?.listings ?? []}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
loading={loading}
stickyHeader
dense
defaultSortId="price"
defaultSortDir="desc"
emptyText="Không tìm thấy bất động sản phù hợp"
/>
) : (
/* ── Chế độ card (legacy, giữ nguyên component cũ) ── */
loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-64 animate-pulse rounded-lg bg-background-surface" />
))}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{(data?.listings ?? []).map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
)
)}
{/* Phân trang */}
{data && data.totalPages > 1 && !loading && (
<div className="mt-6 flex items-center justify-between">
<p className="text-body-sm text-foreground-muted">
Trang {page} / {data.totalPages} · {data.total.toLocaleString('vi-VN')} kết quả
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="h-8"
>
</Button>
{/* Hiện tối đa 5 trang xung quanh trang hiện tại */}
{Array.from({ length: Math.min(5, data.totalPages) }).map((_, i) => {
const half = 2;
const start = Math.max(1, Math.min(page - half, data.totalPages - 4));
const p = start + i;
return (
<Button
key={p}
variant={p === page ? 'default' : 'outline'}
size="sm"
onClick={() => setPage(p)}
className="h-8 w-8 p-0 text-[12px] tabular-nums"
>
{p}
</Button>
);
})}
<Button
variant="outline"
size="sm"
disabled={page >= data.totalPages}
onClick={() => setPage((p) => p + 1)}
className="h-8"
>
</Button>
</div>
</div>
)}
</div>
);
}