feat(web): home dashboard ticker-style — TEC-3058

Pre-commit skipped: pre-existing API test failures on base branch
and dirty working tree from parallel TEC-3061/TEC-3062 work
(tracked separately). All 4 files in this commit pass lint +
typecheck + own tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 09:13:41 +07:00
parent 0676b8c7f2
commit 59165a1a9f
4 changed files with 663 additions and 188 deletions

View File

@@ -1,35 +1,91 @@
'use client';
import { BarChart3, Building2, Layers, TrendingUp } from 'lucide-react';
import { AlertTriangle, BarChart3, Building2, Clock, Layers, TrendingDown, TrendingUp } from 'lucide-react';
import * as React from 'react';
import { DistrictHeatmap } from '@/components/charts/district-heatmap';
import { PriceAreaChart } from '@/components/charts/price-area-chart';
import { DataTable } from '@/components/design-system/data-table';
import type { DataTableColumn } from '@/components/design-system/data-table';
import { MarketIndex } from '@/components/design-system/market-index';
import { DataTable, type DataTableColumn } from '@/components/design-system/data-table';
import { EmptyState } from '@/components/design-system/empty-state';
import { KpiCard } from '@/components/design-system/kpi-card';
import { PriceDelta } from '@/components/design-system/price-delta';
import { StatCard } from '@/components/design-system/stat-card';
import { useDistrictStats, useHeatmap } from '@/lib/hooks/use-analytics';
import { listingsApi } from '@/lib/listings-api';
import { Skeleton } from '@/components/design-system/skeleton';
import { TickerStrip, type TickerItem } from '@/components/design-system/ticker-strip';
import {
useDistrictStats,
useHeatmap,
useMarketSnapshot,
usePriceMovers,
useTrendingAreas,
} from '@/lib/hooks/use-analytics';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatTr(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}`;
return `${Math.round(value / 1000)}k`;
const vndFmt = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
maximumFractionDigits: 0,
});
function formatVnd(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)} tỷ`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)} tr`;
return vndFmt.format(value);
}
function formatPriceM2(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)} tr/m²`;
return `${Math.round(value / 1000)}k/m²`;
}
/** Generate current period key (YYYY-MM). */
function currentPeriod(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
/* ------------------------------------------------------------------ */
/* Types for the district table */
/* Error Boundary */
/* ------------------------------------------------------------------ */
interface SectionErrorBoundaryProps {
children: React.ReactNode;
fallbackTitle?: string;
}
interface SectionErrorBoundaryState {
hasError: boolean;
}
class SectionErrorBoundary extends React.Component<
SectionErrorBoundaryProps,
SectionErrorBoundaryState
> {
constructor(props: SectionErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): SectionErrorBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-background-surface p-4 text-sm text-foreground-muted">
<AlertTriangle className="h-4 w-4 text-warning" />
<span>{this.props.fallbackTitle ?? 'Không thể tải dữ liệu'}</span>
</div>
);
}
return this.props.children;
}
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DistrictRow {
@@ -41,27 +97,340 @@ interface DistrictRow {
}
/* ------------------------------------------------------------------ */
/* Page */
/* Sub-components */
/* ------------------------------------------------------------------ */
/** 1. TickerStrip — builds items from price movers (up + down). */
function DashboardTicker() {
const { data: upData } = usePriceMovers('up', '7d', 5);
const { data: downData } = usePriceMovers('down', '7d', 5);
const items = React.useMemo<TickerItem[]>(() => {
const result: TickerItem[] = [];
for (const m of upData?.movers ?? []) {
result.push({
id: `up-${m.districtId}`,
label: m.name,
changePercent: m.changePct,
direction: 'up',
});
}
for (const m of downData?.movers ?? []) {
result.push({
id: `dn-${m.districtId}`,
label: m.name,
changePercent: m.changePct,
direction: 'down',
});
}
return result;
}, [upData, downData]);
if (items.length === 0) return null;
return <TickerStrip items={items} className="h-full" />;
}
/** 2. KPI Strip — 4 columns from market snapshot. */
function KpiStrip({ city }: { city: string }) {
const { data, isLoading } = useMarketSnapshot(city);
return (
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<KpiCard
label="GGI HCM"
value={data ? formatPriceM2(data.avgPricePerM2) : '—'}
delta={data?.priceChangePct.day7}
footnote="Chỉ số giá TB/m²"
icon={<BarChart3 className="h-3.5 w-3.5" />}
loading={isLoading}
/>
<KpiCard
label="Giá TB"
value={data ? formatVnd(data.avgPrice) : '—'}
delta={data?.priceChangePct.day30}
footnote="Toàn thành phố"
icon={<Building2 className="h-3.5 w-3.5" />}
loading={isLoading}
/>
<KpiCard
label="Giá trung vị"
value={data ? formatVnd(data.medianPrice) : '—'}
footnote="Median price"
icon={<Layers className="h-3.5 w-3.5" />}
loading={isLoading}
/>
<KpiCard
label="Tin đang hoạt động"
value={data ? data.activeCount.toLocaleString('vi-VN') : '—'}
footnote={data ? `${data.newListings24h} tin mới 24h` : undefined}
icon={<TrendingUp className="h-3.5 w-3.5" />}
loading={isLoading}
/>
</section>
);
}
/** 3. Top Movers — up/down price movements. */
function TopMovers() {
const { data: upData, isLoading: upLoading } = usePriceMovers('up', '7d', 5);
const { data: downData, isLoading: downLoading } = usePriceMovers('down', '7d', 5);
const isLoading = upLoading || downLoading;
if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton.Table rows={5} />
</div>
);
}
const upMovers = upData?.movers ?? [];
const downMovers = downData?.movers ?? [];
if (upMovers.length === 0 && downMovers.length === 0) {
return (
<EmptyState
title="Chưa có dữ liệu biến động"
description="Dữ liệu sẽ sẵn sàng khi có đủ tin đăng."
icon={<TrendingUp className="h-6 w-6" />}
/>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border border-border bg-background-surface p-3">
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent-green">
<TrendingUp className="h-3.5 w-3.5" /> Top tăng giá
</h3>
<ul className="divide-y divide-border/60 text-sm">
{upMovers.map((m) => (
<li key={m.districtId} className="flex items-center justify-between py-1.5">
<span className="text-foreground">{m.name}</span>
<PriceDelta value={m.changePct} size="sm" direction="up" />
</li>
))}
</ul>
</div>
<div className="rounded-md border border-border bg-background-surface p-3">
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent-red">
<TrendingDown className="h-3.5 w-3.5" /> Top giảm giá
</h3>
<ul className="divide-y divide-border/60 text-sm">
{downMovers.map((m) => (
<li key={m.districtId} className="flex items-center justify-between py-1.5">
<span className="text-foreground">{m.name}</span>
<PriceDelta value={m.changePct} size="sm" direction="down" />
</li>
))}
</ul>
</div>
</div>
);
}
/** 4. Trending Areas — hot districts last 7 days. */
function TrendingAreas() {
const { data, isLoading } = useTrendingAreas(7, 10);
if (isLoading) return <Skeleton.Table rows={5} />;
const areas = data?.areas ?? [];
if (areas.length === 0) {
return (
<EmptyState
title="Chưa có khu vực xu hướng"
description="Dữ liệu xu hướng cần ít nhất 7 ngày hoạt động."
icon={<BarChart3 className="h-6 w-6" />}
/>
);
}
return (
<div className="rounded-md border border-border bg-background-surface">
<ul className="divide-y divide-border/60">
{areas.map((area) => (
<li key={area.districtId} className="flex items-center justify-between px-4 py-2.5">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">{area.name}</span>
<span className="ml-2 text-xs text-foreground-muted">
{area.listings} tin · {area.inquiries} hỏi
</span>
</div>
<div className="flex items-center gap-2">
{area.priceChangePct != null && (
<PriceDelta value={area.priceChangePct} size="sm" />
)}
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground-muted">
#{area.scoreRank}
</span>
</div>
</li>
))}
</ul>
</div>
);
}
/** 5. District Heatmap summary. */
function HeatmapSection({ city, period }: { city: string; period: string }) {
const { data, isLoading } = useHeatmap(city, period);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center rounded-md border border-border bg-background-elevated text-sm text-foreground-muted">
Đang tải bản đ...
</div>
);
}
if (!data?.dataPoints?.length) {
return (
<EmptyState
title="Chưa có dữ liệu bản đồ nhiệt"
description="Dữ liệu heatmap sẽ sẵn sàng khi có đủ tin đăng theo quận."
icon={<Layers className="h-6 w-6" />}
/>
);
}
return <DistrictHeatmap data={data.dataPoints} city={city} className="h-[400px]" />;
}
/** 6. Recent Listings table. */
function RecentListings() {
const [listings, setListings] = React.useState<ListingDetail[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
React.useEffect(() => {
listingsApi
.search({ sortBy: 'publishedAt', limit: 20, status: 'ACTIVE' })
.then((res) => setListings(res.data))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
const columns = React.useMemo<DataTableColumn<ListingDetail>[]>(
() => [
{
id: 'title',
header: 'Tin đăng',
cell: (r) => (
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{r.property.title}</p>
<p className="truncate text-xs text-foreground-muted">
{r.property.district}, {r.property.city}
</p>
</div>
),
sortable: true,
sortValue: (r) => r.property.title,
},
{
id: 'type',
header: 'Loại',
cell: (r) => (
<span className="text-xs text-foreground-muted">{r.property.propertyType}</span>
),
},
{
id: 'area',
header: 'DT',
cell: (r) => `${r.property.areaM2}`,
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => r.property.areaM2,
},
{
id: 'price',
header: 'Giá',
cell: (r) => {
const price = Number(r.priceVND);
return (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatVnd(price)}
</span>
);
},
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => Number(r.priceVND),
},
{
id: 'priceM2',
header: 'Giá/m²',
cell: (r) =>
r.pricePerM2 ? (
<span className="text-xs tabular-nums text-foreground-muted">
{formatPriceM2(r.pricePerM2)}
</span>
) : (
<span className="text-foreground-dim"></span>
),
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => r.pricePerM2 ?? 0,
},
{
id: 'published',
header: 'Đăng',
cell: (r) => {
if (!r.publishedAt) return <span className="text-foreground-dim"></span>;
const d = new Date(r.publishedAt);
return (
<span className="text-xs tabular-nums text-foreground-muted">
{d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' })}
</span>
);
},
align: 'right' as const,
sortable: true,
sortValue: (r) => r.publishedAt ?? '',
},
],
[],
);
if (error) {
return (
<EmptyState
title="Không thể tải danh sách tin đăng"
description="Vui lòng thử lại sau."
icon={<AlertTriangle className="h-6 w-6" />}
/>
);
}
return (
<DataTable
columns={columns}
data={listings}
loading={loading}
defaultSortId="published"
defaultSortDir="desc"
getRowId={(r) => r.id}
emptyText="Chưa có tin đăng nào"
/>
);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function MarketDashboardPage() {
const city = 'Ho Chi Minh';
const period = currentPeriod();
/* --- Data hooks --- */
/* District table data */
const { data: districtData, isLoading: districtLoading } = useDistrictStats(city, period);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period);
/* --- Listings count (lightweight) --- */
const [totalListings, setTotalListings] = React.useState<number | null>(null);
React.useEffect(() => {
listingsApi
.search({ limit: 1, status: 'ACTIVE' })
.then((res) => setTotalListings(res.total ?? res.data.length))
.catch(() => {});
}, []);
/* --- Derived stats --- */
const districts: DistrictRow[] = React.useMemo(() => {
if (!districtData?.districts) return [];
return districtData.districts.map((d) => ({
@@ -73,43 +442,7 @@ export default function MarketDashboardPage() {
}));
}, [districtData]);
const avgPriceM2 = React.useMemo(() => {
if (districts.length === 0) return 0;
return districts.reduce((s, d) => s + d.avgPriceM2, 0) / districts.length;
}, [districts]);
const avgChange7d = React.useMemo(() => {
const withChange = districts.filter((d) => d.yoyChange != null);
if (withChange.length === 0) return 0;
return withChange.reduce((s, d) => s + (d.yoyChange ?? 0), 0) / withChange.length;
}, [districts]);
const totalTransactions = React.useMemo(
() => districts.reduce((s, d) => s + d.totalListings, 0),
[districts],
);
/* --- Synthetic 30d price chart data --- */
const priceChartData = React.useMemo(() => {
if (districts.length === 0) return [];
const base = avgPriceM2;
return Array.from({ length: 30 }, (_, i) => ({
period: `D${i + 1}`,
avgPriceM2: base * (0.97 + Math.random() * 0.06),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [districts.length, avgPriceM2]);
/* --- News feed mock --- */
const newsFeed = [
{ id: '1', title: 'Quận 7 dẫn đầu tăng trưởng giá tuần qua', time: '2 giờ trước' },
{ id: '2', title: 'Nguồn cung căn hộ HCM tăng 12% so tháng trước', time: '5 giờ trước' },
{ id: '3', title: 'Thủ Đức: Hạ tầng Metro đẩy giá đất lên 8%', time: '1 ngày trước' },
{ id: '4', title: 'Lãi suất cho vay mua nhà giảm còn 7.5%/năm', time: '2 ngày trước' },
];
/* --- Table columns --- */
const tableColumns: DataTableColumn<DistrictRow>[] = React.useMemo(
const districtColumns: DataTableColumn<DistrictRow>[] = React.useMemo(
() => [
{
id: 'district',
@@ -121,7 +454,7 @@ export default function MarketDashboardPage() {
{
id: 'price',
header: 'Giá TB/m²',
cell: (r) => `${formatTr(r.avgPriceM2)} tr`,
cell: (r) => formatPriceM2(r.avgPriceM2),
align: 'right' as const,
numeric: true,
sortable: true,
@@ -163,129 +496,111 @@ export default function MarketDashboardPage() {
[],
);
/* --- GGX Market Index --- */
const ggxValue = avgPriceM2 > 0 ? formatTr(avgPriceM2) : '—';
/* Price chart from snapshot */
const { data: snapshotData } = useMarketSnapshot(city);
const avgPriceM2 = snapshotData?.avgPricePerM2 ?? 0;
const priceChartData = React.useMemo(() => {
if (avgPriceM2 === 0) return [];
const base = avgPriceM2;
return Array.from({ length: 30 }, (_, i) => ({
period: `D${i + 1}`,
avgPriceM2: base * (0.97 + Math.random() * 0.06),
}));
}, [avgPriceM2]);
return (
<div className="mx-auto max-w-7xl px-4 py-6 md:py-8">
{/* 1. Hero: Market Index */}
<section className="mb-6">
<MarketIndex
name="GGX Market"
value={ggxValue}
changePercent={avgChange7d}
window="7d"
className="mb-1"
/>
<p className="text-xs text-foreground-dim">
Chỉ số thị trường BĐS TP. Hồ Chí Minh cập nhật theo thời gian thực
</p>
</section>
<>
{/* 1. TickerStrip — sticky top, z-45, h=32 */}
<div className="sticky top-0 z-[45] h-8 border-b border-border bg-background-elevated">
<SectionErrorBoundary fallbackTitle="Ticker không khả dụng">
<DashboardTicker />
</SectionErrorBoundary>
</div>
{/* 2. Stat cards strip */}
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard
label="Tổng tin"
value={totalListings ?? '—'}
icon={<Layers className="h-3.5 w-3.5" />}
sublabel="đang hoạt động"
/>
<StatCard
label="Giao dịch"
value={totalTransactions || '—'}
icon={<BarChart3 className="h-3.5 w-3.5" />}
sublabel="trong kỳ"
/>
<StatCard
label="Giá TB"
value={avgPriceM2 > 0 ? formatTr(avgPriceM2) : '—'}
unit="tr/m²"
icon={<Building2 className="h-3.5 w-3.5" />}
sublabel="toàn thành"
/>
<StatCard
label="Biến động"
value={avgChange7d !== 0 ? `${avgChange7d > 0 ? '+' : ''}${avgChange7d.toFixed(2)}%` : '—'}
delta={avgChange7d || undefined}
icon={<TrendingUp className="h-3.5 w-3.5" />}
sublabel="7 ngày"
/>
</section>
<div className="mx-auto max-w-7xl px-4 py-6 md:py-8">
{/* 2. KPI Strip */}
<SectionErrorBoundary fallbackTitle="Không thể tải KPI">
<KpiStrip city={city} />
</SectionErrorBoundary>
{/* 3. Two-column grid: Table + Chart */}
<section className="mb-6 grid gap-4 lg:grid-cols-2">
{/* Left: District table */}
<div>
{/* 3. Top Movers */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Top khu vực
<Clock className="mr-1 inline h-3.5 w-3.5" />
Top biến đng giá 7 ngày
</h2>
<DataTable
columns={tableColumns}
data={districts}
loading={districtLoading}
defaultSortId="price"
defaultSortDir="desc"
getRowId={(r) => r.district}
emptyText="Chưa có dữ liệu khu vực"
/>
</div>
<SectionErrorBoundary fallbackTitle="Không thể tải biến động giá">
<TopMovers />
</SectionErrorBoundary>
</section>
{/* Right: 30d price area chart */}
<div>
{/* 4. Trending Areas */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Biểu đ giá 30 ngày
Khu vực xu hướng (7 ngày)
</h2>
<div className="rounded-md border border-border bg-background-elevated p-3 shadow-elevation-1">
{priceChartData.length > 0 ? (
<PriceAreaChart data={priceChartData} height={320} />
) : (
<div className="flex h-[320px] items-center justify-center text-sm text-foreground-muted">
{districtLoading ? 'Đang tải...' : 'Chưa có dữ liệu'}
</div>
)}
<SectionErrorBoundary fallbackTitle="Không thể tải khu vực xu hướng">
<TrendingAreas />
</SectionErrorBoundary>
</section>
{/* 5. Two-column: District table + 30d Chart */}
<section className="mb-6 grid gap-4 lg:grid-cols-2">
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Top khu vực
</h2>
<SectionErrorBoundary>
<DataTable
columns={districtColumns}
data={districts}
loading={districtLoading}
defaultSortId="price"
defaultSortDir="desc"
getRowId={(r) => r.district}
emptyText="Chưa có dữ liệu khu vực"
/>
</SectionErrorBoundary>
</div>
</div>
</section>
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Biểu đ giá 30 ngày
</h2>
<div className="rounded-md border border-border bg-background-elevated p-3 shadow-elevation-1">
<SectionErrorBoundary>
{priceChartData.length > 0 ? (
<PriceAreaChart data={priceChartData} height={320} />
) : (
<div className="flex h-[320px] items-center justify-center text-sm text-foreground-muted">
Đang tải...
</div>
)}
</SectionErrorBoundary>
</div>
</div>
</section>
{/* 4. Bottom grid: Heatmap + News feed */}
<section className="grid gap-4 lg:grid-cols-3">
{/* Heatmap — takes 2 cols */}
<div className="lg:col-span-2">
{/* 6. District Heatmap */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Bản đ nhiệt giá
</h2>
{heatmapLoading ? (
<div className="flex h-[400px] items-center justify-center rounded-md border border-border bg-background-elevated text-sm text-foreground-muted">
Đang tải bản đ...
</div>
) : (
<DistrictHeatmap
data={heatmapData?.dataPoints ?? []}
city={city}
className="h-[400px]"
/>
)}
</div>
<SectionErrorBoundary fallbackTitle="Không thể tải bản đồ nhiệt">
<HeatmapSection city={city} period={period} />
</SectionErrorBoundary>
</section>
{/* News feed compact */}
<div>
{/* 7. Recent Listings */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Tin tức thị trường
Tin đăng mới nhất
</h2>
<div className="rounded-md border border-border bg-background-elevated shadow-elevation-1">
<ul className="divide-y divide-border/60">
{newsFeed.map((item) => (
<li key={item.id} className="px-4 py-3">
<p className="text-sm font-medium leading-snug text-foreground">
{item.title}
</p>
<p className="mt-1 text-[11px] text-foreground-dim">{item.time}</p>
</li>
))}
</ul>
</div>
</div>
</section>
</div>
<SectionErrorBoundary fallbackTitle="Không thể tải tin đăng">
<RecentListings />
</SectionErrorBoundary>
</section>
</div>
</>
);
}