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>
449 lines
18 KiB
TypeScript
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 có 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 có 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 có 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 kê 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 có 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 có 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>
|
|
);
|
|
}
|