Files
goodgo-platform/apps/web/app/[locale]/(dashboard)/analytics/page.tsx
Ho Ngoc Hai 7195064f12 feat(web): add i18n locale routes and language switcher component
Add locale-prefixed routes for admin, auth, dashboard, and public pages.
Add error, loading, and not-found pages for locale context. Add language
switcher UI component for Vietnamese/English toggle.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-09 09:44:18 +07:00

422 lines
17 KiB
TypeScript

'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
useMarketReport,
useHeatmap,
useDistrictStats,
usePriceTrend,
} from '@/lib/hooks/use-analytics';
const DistrictBarChart = dynamic(
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đ...</div> },
);
const PriceTrendChart = dynamic(
() => import('@/components/charts/price-trend-chart').then((mod) => mod.PriceTrendChart),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đ...</div> },
);
const DistrictHeatmap = dynamic(
() => import('@/components/charts/district-heatmap').then((mod) => mod.DistrictHeatmap),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải bản đ nhiệt...</div> },
);
const AgentPerformance = dynamic(
() => import('@/components/charts/agent-performance').then((mod) => mod.AgentPerformance),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải...</div> },
);
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
const CURRENT_PERIOD = '2026-Q1';
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} đ/m²`;
}
function YoYBadge({ value }: { value: number | null }) {
if (value === null) return <span className="text-xs text-muted-foreground">N/A</span>;
const isPositive = value >= 0;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${isPositive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{isPositive ? '+' : ''}
{value.toFixed(1)}%
</span>
);
}
export default function AnalyticsPage() {
const [city, setCity] = useState<string>(CITIES[0] ?? 'Ho Chi Minh');
const period = CURRENT_PERIOD;
const [tab, setTab] = useState('overview');
const [trendDistrict, setTrendDistrict] = useState<string>('');
const { data: reportData, isLoading: reportLoading, error: reportError } = useMarketReport(city, period);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period);
const { data: statsData, isLoading: statsLoading } = useDistrictStats(city, period);
const { data: trendData, isLoading: trendLoading } = usePriceTrend(
trendDistrict,
city,
'APARTMENT',
TREND_PERIODS,
);
const loading = reportLoading || heatmapLoading || statsLoading;
const error = reportError ? 'Không thể tải dữ liệu phân tích' : null;
const marketReport = reportData?.districts ?? [];
const heatmap = heatmapData?.dataPoints ?? [];
const districtStats = statsData?.districts ?? [];
const priceTrend = trendData?.trend ?? [];
// Auto-select first district for trend
const firstDistrict = marketReport[0]?.district ?? '';
useEffect(() => {
if (firstDistrict && !trendDistrict) {
setTrendDistrict(firstDistrict);
}
}, [firstDistrict, trendDistrict]);
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const uniqueDistricts = [...new Set(marketReport.map((d) => d.district))];
// Chart data for bar chart
const barChartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
price: Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
// Chart data for line chart
const trendChartData = priceTrend.map((p) => ({
period: p.period,
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
'Tin đăng': p.totalListings,
}));
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Phân tích thị trường</h1>
<p className="mt-2 text-muted-foreground">
Báo cáo thị trường bất đng sản - {period}
</p>
</div>
<div className="flex gap-2">
{CITIES.map((c) => (
<Button
key={c}
variant={city === c ? 'default' : 'outline'}
size="sm"
onClick={() => setCity(c)}
>
{c}
</Button>
))}
</div>
</div>
{error && <div className="rounded-md bg-red-50 p-4 text-red-700">{error}</div>}
{/* Summary Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng tin đăng</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Giá TB/m²</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Ngày trung bình đ bán</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Số quận/huyện</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Tabs */}
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
<TabsTrigger value="performance">Hiệu suất</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="mt-4 grid gap-6 lg:grid-cols-2">
{/* Bar Chart - Price by District */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
<CardDescription>Triệu VND/m² tại {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : barChartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<DistrictBarChart data={barChartData} height={300} dataKey="price" />
)}
</CardContent>
</Card>
{/* Heatmap - Mapbox Map */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Bản đ nhiệt giá theo quận</CardTitle>
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : heatmap.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<DistrictHeatmap
data={heatmap}
city={city}
className="h-[350px]"
onDistrictClick={(district) => {
setTrendDistrict(district);
setTab('trends');
}}
/>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Trends Tab */}
<TabsContent value="trends">
<div className="mt-4 space-y-6">
{/* District selector */}
<div className="flex flex-wrap gap-2">
{uniqueDistricts.map((d) => (
<Button
key={d}
variant={trendDistrict === d ? 'default' : 'outline'}
size="sm"
onClick={() => setTrendDistrict(d)}
>
{d}
</Button>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">
Xu hướng giá - {trendDistrict || 'Chọn quận'}
</CardTitle>
<CardDescription>
Biến đng giá trung bình/m² qua các quý (Căn hộ)
</CardDescription>
</CardHeader>
<CardContent>
{trendLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : trendChartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu xu hướng
</div>
) : (
<PriceTrendChart data={trendChartData} height={350} />
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* District Stats Tab */}
<TabsContent value="districts">
<div className="mt-4 space-y-6">
{/* Stats Table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Thống chi tiết theo quận</CardTitle>
<CardDescription>
Dữ liệu thị trường bất đng sản tại {city} - {period}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : districtStats.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 pr-4 font-medium">Quận</th>
<th className="pb-2 pr-4 font-medium">Loại BĐS</th>
<th className="pb-2 pr-4 font-medium text-right">Giá trung vị</th>
<th className="pb-2 pr-4 font-medium text-right">Giá/m²</th>
<th className="pb-2 pr-4 font-medium text-right">Tin đăng</th>
<th className="pb-2 pr-4 font-medium text-right">Ngày bán</th>
<th className="pb-2 font-medium text-right">YoY</th>
</tr>
</thead>
<tbody>
{districtStats.map((stat, i) => (
<tr
key={`${stat.district}-${stat.propertyType}-${i}`}
className="border-b last:border-0"
>
<td className="py-2 pr-4">{stat.district}</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">
{stat.propertyType}
</td>
<td className="py-2 pr-4 text-right font-medium">
{formatPrice(stat.medianPrice)}
</td>
<td className="py-2 pr-4 text-right">
{formatPriceM2(stat.avgPriceM2)}
</td>
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
<td className="py-2 pr-4 text-right">
{stat.daysOnMarket.toFixed(0)}
</td>
<td className="py-2 text-right">
<YoYBadge value={stat.yoyChange} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Market Report Cards */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Báo cáo thị trường</CardTitle>
<CardDescription>Tổng hợp chỉ số thị trường theo từng quận</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : marketReport.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...new Map(marketReport.map((d) => [d.district, d])).values()].map(
(district) => (
<div key={district.district} className="rounded-lg border p-4">
<h3 className="font-semibold">{district.district}</h3>
<div className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Giá trung vị</span>
<span className="font-medium">
{formatPrice(district.medianPrice)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Giá/m²</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin đăng</span>
<span>{district.totalListings}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tồn kho</span>
<span>{district.inventoryLevel}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Thay đi YoY</span>
<YoYBadge value={district.yoyChange} />
</div>
</div>
</div>
),
)}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Agent Performance Tab */}
<TabsContent value="performance">
<div className="mt-4">
<AgentPerformance />
</div>
</TabsContent>
</Tabs>
</div>
);
}