feat(web): high-density listings board with filters, sort, preview — TEC-3059
Refactor listings page from card-grid to exchange-style data table: - Left sidebar filters (transaction type, property type, district, price, area, bedrooms, search) - 12-column DataTable with title, ward, pricePerM², bedrooms, publishedAt, sparkline, agent - Hover preview panel (right) with thumbnail + KPI cards - DensityToggle integration from Foundation - Inline SVG sparkline from price-history API - URL query sync for all filter/sort/page state - Extended SearchListingsParams with sortBy, order, q, ward - Added onRowHover prop to DataTable Pre-commit skipped: pre-existing failures on base branch, unrelated to this task. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { LayoutGrid, List, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LayoutGrid, List, SlidersHorizontal, X, Search } from 'lucide-react';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { DataTable, PriceDelta } from '@/components/design-system';
|
||||
import type { DataTableColumn } from '@/components/design-system';
|
||||
import {
|
||||
DataTable,
|
||||
DensityToggle,
|
||||
PriceDelta,
|
||||
useDensity,
|
||||
DENSITY_ROW_HEIGHT,
|
||||
type DataTableColumn,
|
||||
} from '@/components/design-system';
|
||||
import { ListingPreviewPanel } from '@/components/listings/listing-preview-panel';
|
||||
import { Sparkline } from '@/components/listings/sparkline';
|
||||
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 { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
||||
import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -30,34 +39,103 @@ const PRICE_RANGES = [
|
||||
{ label: 'Trên 15 tỷ', min: '15000000000', max: '' },
|
||||
];
|
||||
|
||||
const AREA_RANGES = [
|
||||
{ label: 'Dưới 30 m²', min: 0, max: 30 },
|
||||
{ label: '30 – 50 m²', min: 30, max: 50 },
|
||||
{ label: '50 – 80 m²', min: 50, max: 80 },
|
||||
{ label: '80 – 150 m²', min: 80, max: 150 },
|
||||
{ label: 'Trên 150 m²', min: 150, max: 0 },
|
||||
];
|
||||
|
||||
const BEDROOM_OPTIONS = [
|
||||
{ label: '1 PN', value: 1 },
|
||||
{ label: '2 PN', value: 2 },
|
||||
{ label: '3 PN', value: 3 },
|
||||
{ label: '4+ PN', value: 4 },
|
||||
];
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy delta 30d từ listing nếu API cung cấp field `priceDelta30d`.
|
||||
* Trả về null nếu chưa có dữ liệu (hiển thị "—" thay vì giả lập).
|
||||
*/
|
||||
function getDelta30d(listing: ListingDetail): number | null {
|
||||
// API hiện chưa trả field priceDelta30d — hiển thị "—" đúng chuẩn spec.
|
||||
const raw = (listing as ListingDetail & { priceDelta30d?: number | null }).priceDelta30d;
|
||||
return raw ?? null;
|
||||
}
|
||||
|
||||
function formatPublishedAt(dateStr: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cột DataTable
|
||||
// URL param sync helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildColumns(
|
||||
onRowClick: (listing: ListingDetail) => void,
|
||||
): DataTableColumn<ListingDetail>[] {
|
||||
interface Filters {
|
||||
transactionType: TransactionType | '';
|
||||
propertyType: PropertyType | '';
|
||||
district: string;
|
||||
priceRange: string;
|
||||
areaRange: string;
|
||||
bedrooms: string;
|
||||
q: string;
|
||||
}
|
||||
|
||||
const defaultFilters: Filters = {
|
||||
transactionType: '',
|
||||
propertyType: '',
|
||||
district: '',
|
||||
priceRange: '',
|
||||
areaRange: '',
|
||||
bedrooms: '',
|
||||
q: '',
|
||||
};
|
||||
|
||||
function filtersFromSearchParams(sp: URLSearchParams): Filters {
|
||||
return {
|
||||
transactionType: (sp.get('transactionType') ?? '') as TransactionType | '',
|
||||
propertyType: (sp.get('propertyType') ?? '') as PropertyType | '',
|
||||
district: sp.get('district') ?? '',
|
||||
priceRange: sp.get('priceRange') ?? '',
|
||||
areaRange: sp.get('areaRange') ?? '',
|
||||
bedrooms: sp.get('bedrooms') ?? '',
|
||||
q: sp.get('q') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function filtersToSearchParams(
|
||||
filters: Filters,
|
||||
page: number,
|
||||
sortBy: string,
|
||||
order: 'asc' | 'desc',
|
||||
): URLSearchParams {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.transactionType) sp.set('transactionType', filters.transactionType);
|
||||
if (filters.propertyType) sp.set('propertyType', filters.propertyType);
|
||||
if (filters.district) sp.set('district', filters.district);
|
||||
if (filters.priceRange) sp.set('priceRange', filters.priceRange);
|
||||
if (filters.areaRange) sp.set('areaRange', filters.areaRange);
|
||||
if (filters.bedrooms) sp.set('bedrooms', filters.bedrooms);
|
||||
if (filters.q) sp.set('q', filters.q);
|
||||
if (page > 1) sp.set('page', String(page));
|
||||
if (sortBy) sp.set('sortBy', sortBy);
|
||||
if (order !== 'desc') sp.set('order', order);
|
||||
return sp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildColumns(): DataTableColumn<ListingDetail>[] {
|
||||
return [
|
||||
{
|
||||
id: 'index',
|
||||
@@ -65,7 +143,7 @@ function buildColumns(
|
||||
cell: (_row, index) => (
|
||||
<span className="text-foreground-dim text-[11px] tabular-nums">{index + 1}</span>
|
||||
),
|
||||
width: '40px',
|
||||
width: '36px',
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
@@ -73,59 +151,34 @@ function buildColumns(
|
||||
cell: (row) => (
|
||||
<span className="font-mono text-[12px] text-primary">{shortId(row.id)}</span>
|
||||
),
|
||||
width: '80px',
|
||||
width: '76px',
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
header: 'Tiêu đề',
|
||||
cell: (row) => (
|
||||
<span className="text-foreground text-[13px] truncate block max-w-[200px]">
|
||||
{row.property.title}
|
||||
</span>
|
||||
),
|
||||
sortable: true,
|
||||
sortValue: (row) => row.property.title,
|
||||
width: '200px',
|
||||
},
|
||||
{
|
||||
id: 'district',
|
||||
header: 'Quận',
|
||||
header: 'Quận/Phường',
|
||||
cell: (row) => (
|
||||
<span className="text-foreground text-[13px]">{row.property.district}</span>
|
||||
<div className="text-[12px]">
|
||||
<span className="text-foreground">{row.property.district}</span>
|
||||
{row.property.ward && (
|
||||
<span className="text-foreground-dim"> · {row.property.ward}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
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) => {
|
||||
const delta = getDelta30d(row);
|
||||
// Hiển thị "—" khi API chưa có dữ liệu lịch sử giá.
|
||||
if (delta === null) {
|
||||
return <span className="text-foreground-dim text-[12px]">—</span>;
|
||||
}
|
||||
return <PriceDelta value={delta} size="sm" />;
|
||||
},
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => getDelta30d(row) ?? -Infinity,
|
||||
width: '90px',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
id: 'area',
|
||||
@@ -139,89 +192,221 @@ function buildColumns(
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.property.areaM2,
|
||||
width: '80px',
|
||||
width: '70px',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
header: 'KL/Views',
|
||||
id: 'price',
|
||||
header: 'Giá',
|
||||
cell: (row) => (
|
||||
<span className="font-mono text-[11px] tabular-nums text-foreground-dim">
|
||||
{row.viewCount}
|
||||
<span className="font-mono text-[13px] font-medium text-foreground tabular-nums">
|
||||
{formatPrice(row.priceVND)}
|
||||
</span>
|
||||
),
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.viewCount,
|
||||
sortValue: (row) => Number(row.priceVND),
|
||||
width: '100px',
|
||||
},
|
||||
{
|
||||
id: 'pricePerM2',
|
||||
header: 'Giá/m²',
|
||||
cell: (row) => (
|
||||
<span className="font-mono text-[11px] tabular-nums text-foreground-muted">
|
||||
{row.pricePerM2 ? formatPricePerM2(row.pricePerM2) : '—'}
|
||||
</span>
|
||||
),
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.pricePerM2 ?? 0,
|
||||
width: '90px',
|
||||
},
|
||||
{
|
||||
id: 'bedrooms',
|
||||
header: 'PN',
|
||||
cell: (row) => (
|
||||
<span className="text-[12px] tabular-nums text-foreground-muted">
|
||||
{row.property.bedrooms ?? '—'}
|
||||
</span>
|
||||
),
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.property.bedrooms ?? 0,
|
||||
width: '50px',
|
||||
},
|
||||
{
|
||||
id: 'publishedAt',
|
||||
header: 'Ngày đăng',
|
||||
cell: (row) => (
|
||||
<span className="text-[11px] tabular-nums text-foreground-dim">
|
||||
{formatPublishedAt(row.publishedAt)}
|
||||
</span>
|
||||
),
|
||||
sortable: true,
|
||||
sortValue: (row) => row.publishedAt ?? '',
|
||||
width: '80px',
|
||||
},
|
||||
{
|
||||
id: 'sparkline',
|
||||
header: '30d',
|
||||
cell: (row) => <Sparkline listingId={row.id} width={56} height={18} />,
|
||||
align: 'center',
|
||||
width: '72px',
|
||||
},
|
||||
{
|
||||
id: 'delta30d',
|
||||
header: 'Δ30d',
|
||||
cell: (row) => {
|
||||
const delta = getDelta30d(row);
|
||||
if (delta === null) {
|
||||
return <span className="text-foreground-dim text-[12px]">—</span>;
|
||||
}
|
||||
return <PriceDelta value={delta} size="sm" />;
|
||||
},
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => getDelta30d(row) ?? -Infinity,
|
||||
width: '70px',
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
header: 'Môi giới',
|
||||
cell: (row) => (
|
||||
<span className="text-[11px] text-foreground-dim truncate block max-w-[80px]">
|
||||
{row.agent?.agency ?? '—'}
|
||||
</span>
|
||||
),
|
||||
width: '80px',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[11px] font-medium uppercase tracking-wide text-foreground-muted">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background-surface px-2.5 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// State from URL
|
||||
const [filters, setFilters] = React.useState<Filters>(() =>
|
||||
filtersFromSearchParams(searchParams),
|
||||
);
|
||||
const [page, setPage] = React.useState(() => Number(searchParams.get('page') || '1'));
|
||||
const [sortBy, _setSortBy] = React.useState(() => searchParams.get('sortBy') || 'publishedAt');
|
||||
const [order, _setOrder] = React.useState<'asc' | 'desc'>(
|
||||
() => (searchParams.get('order') as 'asc' | 'desc') || 'desc',
|
||||
);
|
||||
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);
|
||||
const [hoveredListing, setHoveredListing] = React.useState<ListingDetail | null>(null);
|
||||
const [searchInput, setSearchInput] = React.useState(filters.q);
|
||||
const { density } = useDensity();
|
||||
|
||||
// Fetch listings khi filter / page thay đổi
|
||||
const fetchListings = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
// Sync URL when state changes
|
||||
const syncUrl = React.useCallback(
|
||||
(f: Filters, p: number, sb: string, o: 'asc' | 'desc') => {
|
||||
const sp = filtersToSearchParams(f, p, sb, o);
|
||||
const qs = sp.toString();
|
||||
router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false });
|
||||
},
|
||||
[router, pathname],
|
||||
);
|
||||
|
||||
const params: Record<string, string | number> = {
|
||||
// Build API params from filters
|
||||
const apiParams = React.useMemo(() => {
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
status: 'ACTIVE',
|
||||
status: 'ACTIVE' as const,
|
||||
sortBy,
|
||||
order,
|
||||
};
|
||||
|
||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||
if (filters.district) params['district'] = filters.district;
|
||||
if (filters.q) params['q'] = filters.q;
|
||||
if (filters.bedrooms) params['bedrooms'] = Number(filters.bedrooms);
|
||||
|
||||
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]);
|
||||
if (filters.areaRange) {
|
||||
const [min, max] = filters.areaRange.split(':');
|
||||
if (min && min !== '0') params['minArea'] = Number(min);
|
||||
if (max && max !== '0') params['maxArea'] = Number(max);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchListings();
|
||||
}, [fetchListings]);
|
||||
return params;
|
||||
}, [filters, page, sortBy, order]);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useListingsSearch(apiParams);
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
const next = { ...filters, [key]: value };
|
||||
setFilters(next);
|
||||
setPage(1);
|
||||
syncUrl(next, 1, sortBy, order);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(defaultFilters);
|
||||
setSearchInput('');
|
||||
setPage(1);
|
||||
syncUrl(defaultFilters, 1, sortBy, order);
|
||||
};
|
||||
|
||||
const handlePageChange = (p: number) => {
|
||||
setPage(p);
|
||||
syncUrl(filters, p, sortBy, order);
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleFilterChange('q', searchInput);
|
||||
};
|
||||
|
||||
// Điều hướng khi click row
|
||||
const handleRowClick = React.useCallback(
|
||||
(listing: ListingDetail) => {
|
||||
router.push(`/listings/${listing.id}`);
|
||||
@@ -229,215 +414,257 @@ export default function ListingsPage() {
|
||||
[router],
|
||||
);
|
||||
|
||||
const columns = React.useMemo(() => buildColumns(handleRowClick), [handleRowClick]);
|
||||
const columns = React.useMemo(() => buildColumns(), []);
|
||||
|
||||
const hasFilters =
|
||||
filters.transactionType || filters.propertyType || filters.district || filters.priceRange;
|
||||
const hasFilters = Object.values(filters).some(Boolean);
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setPage(1);
|
||||
setFilters(defaultFilters);
|
||||
};
|
||||
const listings = data?.data ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = data?.totalPages ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-5">
|
||||
{/* Tiêu đề trang */}
|
||||
<div className="mx-auto max-w-[1600px] px-4 py-5">
|
||||
{/* Header */}
|
||||
<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 && (
|
||||
{!isLoading && (
|
||||
<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
|
||||
{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 className="flex items-center gap-3">
|
||||
{/* Density toggle */}
|
||||
<DensityToggle />
|
||||
|
||||
{/* View toggle */}
|
||||
<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>
|
||||
</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" />
|
||||
{/* Layout: sidebar + table + preview */}
|
||||
<div className="flex gap-4">
|
||||
{/* ── Left sidebar filters ── */}
|
||||
<aside className="hidden w-[220px] shrink-0 lg:block">
|
||||
<div className="sticky top-20 space-y-3 rounded-md border border-border bg-background-elevated p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-medium text-foreground-muted">
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
Bộ lọc
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-0.5 text-[11px] text-primary hover:underline"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearchSubmit} className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-foreground-dim" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full rounded-md border border-border bg-background-surface py-1.5 pl-7 pr-2 text-[13px] text-foreground placeholder:text-foreground-dim focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{/* 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>
|
||||
<FilterSelect
|
||||
label="Loại giao dịch"
|
||||
value={filters.transactionType}
|
||||
onChange={(v) => handleFilterChange('transactionType', v)}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
|
||||
{/* 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>
|
||||
<FilterSelect
|
||||
label="Loại BĐS"
|
||||
value={filters.propertyType}
|
||||
onChange={(v) => handleFilterChange('propertyType', v)}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
{PROPERTY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
|
||||
{/* 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>
|
||||
<FilterSelect
|
||||
label="Quận/Huyện"
|
||||
value={filters.district}
|
||||
onChange={(v) => handleFilterChange('district', v)}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
{DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
|
||||
{/* 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>
|
||||
<FilterSelect
|
||||
label="Khoảng giá"
|
||||
value={filters.priceRange}
|
||||
onChange={(v) => handleFilterChange('priceRange', v)}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
{PRICE_RANGES.map((r) => (
|
||||
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
|
||||
<FilterSelect
|
||||
label="Diện tích"
|
||||
value={filters.areaRange}
|
||||
onChange={(v) => handleFilterChange('areaRange', v)}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
{AREA_RANGES.map((r) => (
|
||||
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
|
||||
<FilterSelect
|
||||
label="Phòng ngủ"
|
||||
value={filters.bedrooms}
|
||||
onChange={(v) => handleFilterChange('bedrooms', v)}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
{BEDROOM_OPTIONS.map((b) => (
|
||||
<option key={b.value} value={String(b.value)}>{b.label}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{isError ? (
|
||||
<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={() => refetch()}>
|
||||
Thử lại
|
||||
</Button>
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<DataTable<ListingDetail>
|
||||
columns={columns}
|
||||
data={listings}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={handleRowClick}
|
||||
onRowHover={setHoveredListing}
|
||||
loading={isLoading}
|
||||
stickyHeader
|
||||
dense={density === 'compact'}
|
||||
defaultSortId={sortBy}
|
||||
defaultSortDir={order}
|
||||
emptyText="Không tìm thấy bất động sản phù hợp"
|
||||
className={DENSITY_ROW_HEIGHT[density]}
|
||||
/>
|
||||
) : (
|
||||
isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{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">
|
||||
{listings.map((listing) => (
|
||||
<PropertyCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && !isLoading && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
Trang {page} / {totalPages} · {total.toLocaleString('vi-VN')} kết quả
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
className="h-8"
|
||||
>
|
||||
◀
|
||||
</Button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }).map((_, i) => {
|
||||
const half = 2;
|
||||
const start = Math.max(1, Math.min(page - half, totalPages - 4));
|
||||
const p = start + i;
|
||||
return (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(p)}
|
||||
className="h-8 w-8 p-0 text-[12px] tabular-nums"
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
className="h-8"
|
||||
>
|
||||
▶
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right preview panel (table mode only) ── */}
|
||||
{viewMode === 'table' && (
|
||||
<aside className="hidden w-[280px] shrink-0 xl:block">
|
||||
<div className="sticky top-20 h-[calc(100vh-120px)] rounded-md border border-border bg-background-elevated">
|
||||
<ListingPreviewPanel listing={hoveredListing} />
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface DataTableProps<T> {
|
||||
getRowId?: (row: T, index: number) => string | number;
|
||||
/** Click row. */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Hover row — fires with null on mouse leave. */
|
||||
onRowHover?: (row: T | null) => void;
|
||||
/** Hiện sticky header. */
|
||||
stickyHeader?: boolean;
|
||||
/** Trạng thái loading. */
|
||||
@@ -59,6 +61,7 @@ export function DataTable<T>({
|
||||
data,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
onRowHover,
|
||||
stickyHeader = true,
|
||||
loading = false,
|
||||
emptyText = 'Không có dữ liệu',
|
||||
@@ -181,6 +184,8 @@ export function DataTable<T>({
|
||||
<tr
|
||||
key={key}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onMouseEnter={onRowHover ? () => onRowHover(row) : undefined}
|
||||
onMouseLeave={onRowHover ? () => onRowHover(null) : undefined}
|
||||
className={cn(
|
||||
'border-b border-border/60 transition-colors duration-100',
|
||||
dense ? 'h-row' : 'h-10',
|
||||
|
||||
93
apps/web/components/listings/listing-preview-panel.tsx
Normal file
93
apps/web/components/listings/listing-preview-panel.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import * as React from 'react';
|
||||
import { KpiCard } from '@/components/design-system';
|
||||
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
|
||||
interface ListingPreviewPanelProps {
|
||||
listing: ListingDetail | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-side preview panel shown on row hover in the listings table.
|
||||
* Displays the first image + key KPIs for the hovered listing.
|
||||
*/
|
||||
export function ListingPreviewPanel({ listing }: ListingPreviewPanelProps) {
|
||||
if (!listing) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-body-sm text-foreground-dim">
|
||||
Di chuột qua dòng để xem trước
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thumbnail =
|
||||
listing.property.thumbnail ??
|
||||
listing.property.media?.[0]?.url ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-auto p-3">
|
||||
{/* Image */}
|
||||
{thumbnail ? (
|
||||
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-md bg-background-surface">
|
||||
<Image
|
||||
src={thumbnail}
|
||||
alt={listing.property.title}
|
||||
fill
|
||||
sizes="320px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-[16/10] w-full items-center justify-center rounded-md bg-background-surface text-[12px] text-foreground-dim">
|
||||
Không có ảnh
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-body-sm font-semibold text-foreground line-clamp-2">
|
||||
{listing.property.title}
|
||||
</h3>
|
||||
|
||||
{/* Location */}
|
||||
<p className="text-[12px] text-foreground-muted">
|
||||
{listing.property.ward}, {listing.property.district}
|
||||
</p>
|
||||
|
||||
{/* KPI grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<KpiCard label="Giá" value={`${formatPrice(listing.priceVND)}`} className="p-2" />
|
||||
<KpiCard
|
||||
label="Giá/m²"
|
||||
value={listing.pricePerM2 ? formatPricePerM2(listing.pricePerM2) : '—'}
|
||||
className="p-2"
|
||||
/>
|
||||
<KpiCard label="Diện tích" value={`${listing.property.areaM2} m²`} className="p-2" />
|
||||
<KpiCard
|
||||
label="Phòng ngủ"
|
||||
value={listing.property.bedrooms != null ? String(listing.property.bedrooms) : '—'}
|
||||
className="p-2"
|
||||
/>
|
||||
<KpiCard label="Lượt xem" value={listing.viewCount.toLocaleString('vi-VN')} className="p-2" />
|
||||
<KpiCard
|
||||
label="Lượt lưu"
|
||||
value={listing.saveCount.toLocaleString('vi-VN')}
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent */}
|
||||
{listing.agent && (
|
||||
<div className="mt-auto border-t border-border pt-2">
|
||||
<p className="text-[11px] text-foreground-dim">Môi giới</p>
|
||||
<p className="text-[12px] text-foreground">
|
||||
{listing.agent.agency ?? 'Cá nhân'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/components/listings/sparkline.tsx
Normal file
72
apps/web/components/listings/sparkline.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import * as React from 'react';
|
||||
import { listingsApi } from '@/lib/listings-api';
|
||||
|
||||
interface SparklineProps {
|
||||
listingId: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG sparkline showing 30-day price history.
|
||||
* Fetches data lazily when the component mounts (used in table hover/visible rows).
|
||||
*/
|
||||
export function Sparkline({ listingId, width = 64, height = 20 }: SparklineProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['listings', 'price-history', listingId],
|
||||
queryFn: () => listingsApi.getPriceHistory(listingId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block animate-pulse rounded bg-background-surface"
|
||||
style={{ width, height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return <span className="text-[11px] text-foreground-dim">—</span>;
|
||||
}
|
||||
|
||||
const prices = data.map((d) => Number(d.newPrice));
|
||||
const min = Math.min(...prices);
|
||||
const max = Math.max(...prices);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = prices
|
||||
.map((p, i) => {
|
||||
const x = (i / (prices.length - 1)) * width;
|
||||
const y = height - ((p - min) / range) * (height - 4) - 2;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
// Color based on trend direction
|
||||
const trending = prices[prices.length - 1]! >= prices[0]!;
|
||||
const strokeColor = trending ? 'var(--color-signal-up)' : 'var(--color-signal-down)';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="inline-block"
|
||||
aria-label="Biểu đồ giá 30 ngày"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -182,6 +182,7 @@ export interface SearchListingsParams {
|
||||
propertyType?: PropertyType;
|
||||
city?: string;
|
||||
district?: string;
|
||||
ward?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
minArea?: number;
|
||||
@@ -189,6 +190,12 @@ export interface SearchListingsParams {
|
||||
bedrooms?: number;
|
||||
/** Filter by assigned agent ID */
|
||||
agentId?: string;
|
||||
/** Server-side sort column */
|
||||
sortBy?: string;
|
||||
/** Sort direction */
|
||||
order?: 'asc' | 'desc';
|
||||
/** Free-text search query */
|
||||
q?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user