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:
@@ -39,6 +39,14 @@ vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
notFound: vi.fn(),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
@@ -70,6 +78,44 @@ vi.mock('@/lib/hooks/use-analytics', () => ({
|
||||
data: { city: 'Ho Chi Minh', period: '2026-04', dataPoints: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useMarketSnapshot: () => ({
|
||||
data: {
|
||||
city: 'Ho Chi Minh',
|
||||
activeCount: 1234,
|
||||
avgPrice: 5_000_000_000,
|
||||
medianPrice: 3_500_000_000,
|
||||
priceChangePct: { day1: 0.1, day7: 1.5, day30: 3.2 },
|
||||
avgPricePerM2: 85_000_000,
|
||||
daysOnMarket: 28,
|
||||
newListings24h: 15,
|
||||
cachedAt: null,
|
||||
nextRefreshAt: null,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
usePriceMovers: (direction: string) => ({
|
||||
data: {
|
||||
direction,
|
||||
period: '7d',
|
||||
level: 'district',
|
||||
limit: 5,
|
||||
movers: direction === 'up'
|
||||
? [{ districtId: 'q1', name: 'Quận 1', currentAvgPrice: 10e9, previousAvgPrice: 9.5e9, changePct: 5.26, sampleSize: 20 }]
|
||||
: [{ districtId: 'q9', name: 'Quận 9', currentAvgPrice: 3e9, previousAvgPrice: 3.2e9, changePct: -6.25, sampleSize: 15 }],
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useTrendingAreas: () => ({
|
||||
data: {
|
||||
period: 7,
|
||||
level: 'district',
|
||||
limit: 10,
|
||||
areas: [
|
||||
{ districtId: 'td', name: 'Thủ Đức', listings: 50, inquiries: 120, views: 3000, priceChangePct: 2.1, scoreRank: 1 },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/charts/district-heatmap', () => ({
|
||||
@@ -96,22 +142,32 @@ describe('MarketDashboardPage', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders GGX Market Index header', async () => {
|
||||
it('renders KPI strip with market snapshot data', async () => {
|
||||
renderWithProviders(<MarketDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GGX Market')).toBeInTheDocument();
|
||||
expect(screen.getByText('GGI HCM')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá TB')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá trung vị')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tin đang hoạt động')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders stat cards', async () => {
|
||||
it('renders top movers with district data', async () => {
|
||||
renderWithProviders(<MarketDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tổng tin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giao dịch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá TB')).toBeInTheDocument();
|
||||
expect(screen.getByText('Biến động')).toBeInTheDocument();
|
||||
// Quận 1 appears in both top movers and ticker; use getAllByText
|
||||
expect(screen.getAllByText('Quận 1').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Quận 9').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders trending areas', async () => {
|
||||
renderWithProviders(<MarketDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Thủ Đức')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,19 +188,13 @@ describe('MarketDashboardPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders heatmap section', async () => {
|
||||
it('renders section headings', async () => {
|
||||
renderWithProviders(<MarketDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders news feed', async () => {
|
||||
renderWithProviders(<MarketDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quận 7 dẫn đầu tăng trưởng giá tuần qua')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Top biến động giá/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Khu vực xu hướng/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Tin đăng mới nhất')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}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 (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,72 @@ export interface ProjectAiAdvice {
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Market Snapshot */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface PriceChangePct {
|
||||
day1: number;
|
||||
day7: number;
|
||||
day30: number;
|
||||
}
|
||||
|
||||
export interface MarketSnapshotResponse {
|
||||
city: string;
|
||||
propertyType?: string;
|
||||
activeCount: number;
|
||||
avgPrice: number;
|
||||
medianPrice: number;
|
||||
priceChangePct: PriceChangePct;
|
||||
avgPricePerM2: number;
|
||||
daysOnMarket: number;
|
||||
newListings24h: number;
|
||||
cachedAt: string | null;
|
||||
nextRefreshAt: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Price Movers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface PriceMoverItem {
|
||||
districtId: string;
|
||||
name: string;
|
||||
currentAvgPrice: number;
|
||||
previousAvgPrice: number;
|
||||
changePct: number;
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
export interface PriceMoversResponse {
|
||||
direction: 'up' | 'down';
|
||||
period: string;
|
||||
level: string;
|
||||
limit: number;
|
||||
movers: PriceMoverItem[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trending Areas */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TrendingAreaItem {
|
||||
districtId: string;
|
||||
name: string;
|
||||
listings: number;
|
||||
inquiries: number;
|
||||
views: number;
|
||||
priceChangePct: number | null;
|
||||
scoreRank: number;
|
||||
}
|
||||
|
||||
export interface TrendingAreasResponse {
|
||||
period: number;
|
||||
level: string;
|
||||
limit: number;
|
||||
areas: TrendingAreaItem[];
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getMarketReport: (city: string, period: string, propertyType?: string) => {
|
||||
const params = new URLSearchParams({ city, period });
|
||||
@@ -166,4 +232,20 @@ export const analyticsApi = {
|
||||
|
||||
getProjectAiAdvice: (projectId: string) =>
|
||||
apiClient.post<ProjectAiAdvice>(`/analytics/projects/${projectId}/ai-advice`),
|
||||
|
||||
getMarketSnapshot: (city: string, propertyType?: string) => {
|
||||
const params = new URLSearchParams({ city });
|
||||
if (propertyType) params.set('propertyType', propertyType);
|
||||
return apiClient.get<MarketSnapshotResponse>(`/analytics/market-snapshot?${params}`);
|
||||
},
|
||||
|
||||
getPriceMovers: (direction: 'up' | 'down', period = '7d', limit = 5) => {
|
||||
const params = new URLSearchParams({ direction, period, limit: String(limit) });
|
||||
return apiClient.get<PriceMoversResponse>(`/analytics/price-movers?${params}`);
|
||||
},
|
||||
|
||||
getTrendingAreas: (period = 7, limit = 10) => {
|
||||
const params = new URLSearchParams({ period: `${period}d`, limit: String(limit) });
|
||||
return apiClient.get<TrendingAreasResponse>(`/analytics/trending-areas?${params}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,12 @@ export const analyticsKeys = {
|
||||
['analytics', 'district-stats', city, period] as const,
|
||||
priceTrend: (district: string, city: string, propertyType: string, periods: string[]) =>
|
||||
['analytics', 'price-trend', district, city, propertyType, periods] as const,
|
||||
marketSnapshot: (city: string) =>
|
||||
['analytics', 'market-snapshot', city] as const,
|
||||
priceMovers: (direction: 'up' | 'down', period: string) =>
|
||||
['analytics', 'price-movers', direction, period] as const,
|
||||
trendingAreas: (period: number) =>
|
||||
['analytics', 'trending-areas', period] as const,
|
||||
};
|
||||
|
||||
export function useMarketReport(city: string, period: string) {
|
||||
@@ -46,3 +52,25 @@ export function usePriceTrend(
|
||||
enabled: !!district && !!city,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketSnapshot(city: string) {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.marketSnapshot(city),
|
||||
queryFn: () => analyticsApi.getMarketSnapshot(city),
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePriceMovers(direction: 'up' | 'down', period = '7d', limit = 5) {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.priceMovers(direction, period),
|
||||
queryFn: () => analyticsApi.getPriceMovers(direction, period, limit),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrendingAreas(period = 7, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.trendingAreas(period),
|
||||
queryFn: () => analyticsApi.getTrendingAreas(period, limit),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user