'use client'; 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, 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 { 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 */ /* ------------------------------------------------------------------ */ 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²`; } function currentPeriod(): string { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; } /* ------------------------------------------------------------------ */ /* 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 (
{this.props.fallbackTitle ?? 'Không thể tải dữ liệu'}
); } return this.props.children; } } /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface DistrictRow { district: string; avgPriceM2: number; yoyChange: number | null; totalListings: number; daysOnMarket: number; } /* ------------------------------------------------------------------ */ /* 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(() => { 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 ; } /** 2. KPI Strip — 4 columns from market snapshot. */ function KpiStrip({ city }: { city: string }) { const { data, isLoading } = useMarketSnapshot(city); return (
} loading={isLoading} /> } loading={isLoading} /> } loading={isLoading} /> } loading={isLoading} />
); } /** 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 (
); } const upMovers = upData?.movers ?? []; const downMovers = downData?.movers ?? []; if (upMovers.length === 0 && downMovers.length === 0) { return ( } /> ); } return (

Top tăng giá

    {upMovers.map((m) => (
  • {m.name}
  • ))}

Top giảm giá

    {downMovers.map((m) => (
  • {m.name}
  • ))}
); } /** 4. Trending Areas — hot districts last 7 days. */ function TrendingAreas() { const { data, isLoading } = useTrendingAreas(7, 10); if (isLoading) return ; const areas = data?.areas ?? []; if (areas.length === 0) { return ( } /> ); } return (
    {areas.map((area) => (
  • {area.name} {area.listings} tin · {area.inquiries} hỏi
    {area.priceChangePct != null && ( )} #{area.scoreRank}
  • ))}
); } /** 5. District Heatmap summary. */ function HeatmapSection({ city, period }: { city: string; period: string }) { const { data, isLoading } = useHeatmap(city, period); if (isLoading) { return (
Đang tải bản đồ...
); } if (!data?.dataPoints?.length) { return ( } /> ); } return ; } /** 6. Recent Listings table. */ function RecentListings() { const [listings, setListings] = React.useState([]); 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[]>( () => [ { id: 'title', header: 'Tin đăng', cell: (r) => (

{r.property.title}

{r.property.district}, {r.property.city}

), sortable: true, sortValue: (r) => r.property.title, }, { id: 'type', header: 'Loại', cell: (r) => ( {r.property.propertyType} ), }, { id: 'area', header: 'DT', cell: (r) => `${r.property.areaM2}m²`, 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 ( {formatVnd(price)} ); }, align: 'right' as const, numeric: true, sortable: true, sortValue: (r) => Number(r.priceVND), }, { id: 'priceM2', header: 'Giá/m²', cell: (r) => r.pricePerM2 ? ( {formatPriceM2(r.pricePerM2)} ) : ( ), align: 'right' as const, numeric: true, sortable: true, sortValue: (r) => r.pricePerM2 ?? 0, }, { id: 'published', header: 'Đăng', cell: (r) => { if (!r.publishedAt) return ; const d = new Date(r.publishedAt); return ( {d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' })} ); }, align: 'right' as const, sortable: true, sortValue: (r) => r.publishedAt ?? '', }, ], [], ); if (error) { return ( } /> ); } return ( r.id} emptyText="Chưa có tin đăng nào" /> ); } /* ------------------------------------------------------------------ */ /* Main Page */ /* ------------------------------------------------------------------ */ export default function MarketDashboardPage() { const city = 'Ho Chi Minh'; const period = currentPeriod(); /* District table data */ const { data: districtData, isLoading: districtLoading } = useDistrictStats(city, period); const districts: DistrictRow[] = React.useMemo(() => { if (!districtData?.districts) return []; return districtData.districts.map((d) => ({ district: d.district, avgPriceM2: d.avgPriceM2, yoyChange: d.yoyChange, totalListings: d.totalListings, daysOnMarket: d.daysOnMarket, })); }, [districtData]); const districtColumns: DataTableColumn[] = React.useMemo( () => [ { id: 'district', header: 'Quận', cell: (r) => {r.district}, sortable: true, sortValue: (r) => r.district, }, { id: 'price', header: 'Giá TB/m²', cell: (r) => formatPriceM2(r.avgPriceM2), align: 'right' as const, numeric: true, sortable: true, sortValue: (r) => r.avgPriceM2, }, { id: 'change', header: 'Δ7d', cell: (r) => r.yoyChange != null ? ( ) : ( ), align: 'right' as const, numeric: true, sortable: true, sortValue: (r) => r.yoyChange ?? 0, }, { id: 'volume', header: 'Vol', cell: (r) => r.totalListings, align: 'right' as const, numeric: true, sortable: true, sortValue: (r) => r.totalListings, }, { id: 'dom', header: 'DT', cell: (r) => `${r.daysOnMarket}d`, align: 'right' as const, numeric: true, sortable: true, sortValue: (r) => r.daysOnMarket, }, ], [], ); /* 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 ( <> {/* 1. TickerStrip — sticky top, z-45, h=32 */}
{/* 2. KPI Strip */} {/* 3. Top Movers */}

Top biến động giá 7 ngày

{/* 4. Trending Areas */}

Khu vực xu hướng (7 ngày)

{/* 5. Two-column: District table + 30d Chart */}

Top khu vực

r.district} emptyText="Chưa có dữ liệu khu vực" />

Biểu đồ giá 30 ngày

{priceChartData.length > 0 ? ( ) : (
Đang tải...
)}
{/* 6. District Heatmap */}

Bản đồ nhiệt giá

{/* 7. Recent Listings */}

Tin đăng mới nhất

); }