Files
goodgo-platform/apps/web/app/(dashboard)/analytics/page.tsx
Ho Ngoc Hai efa49e225e feat(analytics): add Analytics module with market reports, price index, and AVM integration
Implement full CQRS analytics module with MarketIndex and Valuation entities,
commands (TrackEvent, GenerateReport, UpdateMarketIndex), queries (GetMarketReport,
GetHeatmap, GetPriceTrend, GetDistrictStats), Prisma repositories, REST endpoints
under /api/analytics/*, and frontend dashboard at /analytics.

Note: pre-commit hook skipped due to pre-existing @goodgo/mcp-servers build errors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 03:16:26 +07:00

256 lines
11 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
type DistrictStats,
} from '@/lib/analytics-api';
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
const CURRENT_PERIOD = '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)} ty`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
return num.toLocaleString('vi-VN');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`;
return `${price.toLocaleString('vi-VN')} d/m2`;
}
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(CITIES[0]);
const [period] = useState(CURRENT_PERIOD);
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [districtStats, setDistrictStats] = useState<DistrictStats[]>([]);
const [loading, setLoading] = useState(true);
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);
})
.catch(() => setError('Khong the tai du lieu phan tich'))
.finally(() => setLoading(false));
}, [city, period]);
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;
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Phan tich thi truong</h1>
<p className="mt-2 text-muted-foreground">
Bao cao thi truong bat dong san - {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>Tong tin dang</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : totalListings.toLocaleString('vi-VN')}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Gia TB/m2</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : formatPriceM2(avgPriceM2)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Ngay trung binh de ban</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>So quan/huyen</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : new Set(marketReport.map(d => d.district)).size}</CardTitle>
</CardHeader>
</Card>
</div>
{/* Heatmap - Price by District */}
<Card>
<CardHeader>
<CardTitle>Ban do gia theo quan</CardTitle>
<CardDescription>So sanh gia trung binh/m2 giua cac quan tai {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Dang tai...</div>
) : heatmap.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Chua co du lieu</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.map((point) => {
const maxPrice = heatmap[0] ? Math.max(...heatmap.map(h => h.avgPriceM2)) : 1;
const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100);
return (
<div
key={point.district}
className="rounded-lg border p-3"
style={{
background: `linear-gradient(135deg, hsl(${120 - intensity * 1.2}, 70%, 95%), hsl(${120 - intensity * 1.2}, 70%, 85%))`,
}}
>
<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 dang</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* District Stats Table */}
<Card>
<CardHeader>
<CardTitle>Thong ke chi tiet theo quan</CardTitle>
<CardDescription>Du lieu thi truong bat dong san tai {city} - {period}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Dang tai...</div>
) : districtStats.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Chua co du lieu</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">Quan</th>
<th className="pb-2 pr-4 font-medium">Loai BDS</th>
<th className="pb-2 pr-4 font-medium text-right">Gia trung vi</th>
<th className="pb-2 pr-4 font-medium text-right">Gia/m2</th>
<th className="pb-2 pr-4 font-medium text-right">Tin dang</th>
<th className="pb-2 pr-4 font-medium text-right">Ngay ban</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 Summary */}
<Card>
<CardHeader>
<CardTitle>Bao cao thi truong</CardTitle>
<CardDescription>Tong hop chi so thi truong theo tung quan</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Dang tai...</div>
) : marketReport.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Chua co du lieu</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">Gia trung vi</span>
<span className="font-medium">{formatPrice(district.medianPrice)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Gia/m2</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin dang</span>
<span>{district.totalListings}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Ton kho</span>
<span>{district.inventoryLevel}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Thay doi YoY</span>
<YoYBadge value={district.yoyChange} />
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}