Files
goodgo-platform/apps/web/app/(dashboard)/analytics/page.tsx
Ho Ngoc Hai 36c1e3b39a fix(web): add proper Vietnamese diacritics to all dashboard and listing pages
Vietnamese text throughout the frontend was missing accent marks (diacritics),
using plain ASCII instead of proper Unicode characters. Fixed all user-visible
text across dashboard, analytics, listings, search, and chart components.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 13:21:37 +07:00

449 lines
18 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 {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
type DistrictStats,
type PriceTrendPoint,
} from '@/lib/analytics-api';
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 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] = useState(CURRENT_PERIOD);
const [tab, setTab] = useState('overview');
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [districtStats, setDistrictStats] = useState<DistrictStats[]>([]);
const [priceTrend, setPriceTrend] = useState<PriceTrendPoint[]>([]);
const [trendDistrict, setTrendDistrict] = useState<string>('');
const [loading, setLoading] = useState(true);
const [trendLoading, setTrendLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
Promise.all([
analyticsApi
.getMarketReport(city, period)
.catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi
.getHeatmap(city, period)
.catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
analyticsApi
.getDistrictStats(city, period)
.catch(() => ({ districts: [] as DistrictStats[] })),
])
.then(([report, heatmapData, stats]) => {
setMarketReport(report.districts);
setHeatmap(heatmapData.dataPoints);
setDistrictStats(stats.districts);
// Auto-select first district for trend
const firstDistrict = report.districts[0]?.district ?? '';
if (firstDistrict && !trendDistrict) {
setTrendDistrict(firstDistrict);
}
})
.catch(() => setError('Không thể tải dữ liệu phân tích'))
.finally(() => setLoading(false));
}, [city, period]);
// Load price trend when district changes
useEffect(() => {
if (!trendDistrict || !city) return;
setTrendLoading(true);
analyticsApi
.getPriceTrend(trendDistrict, city, 'APARTMENT', TREND_PERIODS)
.then((res) => setPriceTrend(res.trend))
.catch(() => setPriceTrend([]))
.finally(() => setTrendLoading(false));
}, [trendDistrict, city]);
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>
</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 - Card Grid */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Bản đ 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>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.slice(0, 8)
.map((point) => {
const maxPrice = Math.max(...heatmap.map((h) => h.avgPriceM2));
const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100);
return (
<div
key={point.district}
className="cursor-pointer rounded-lg border p-3 transition-shadow hover:shadow-md"
style={{
background: `linear-gradient(135deg, hsl(${120 - intensity * 1.2}, 70%, 95%), hsl(${120 - intensity * 1.2}, 70%, 85%))`,
}}
onClick={() => {
setTrendDistrict(point.district);
setTab('trends');
}}
>
<div className="font-medium">{point.district}</div>
<div className="text-sm font-semibold">
{formatPriceM2(point.avgPriceM2)}
</div>
<div className="text-xs text-muted-foreground">
{point.totalListings} tin đăng
</div>
</div>
);
})}
</div>
)}
</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>
</Tabs>
</div>
);
}