Files
goodgo-platform/apps/web/app/(dashboard)/dashboard/page.tsx
Ho Ngoc Hai 2502aa69b7 fix: production readiness — resolve build, lint, and code quality issues
- Fix Next.js build failure: remove duplicate route at (dashboard)/listings/[id]
  that conflicted with (public)/listings/[id] (same URL path in two route groups)
- Fix 772 ESLint errors: auto-fix import ordering (import-x/order), remove unused
  imports/variables, convert empty interfaces to type aliases, replace require()
  with ESM imports, fix consistent-type-imports violations
- Add CLAUDE.md for developer onboarding documentation
- All checks pass: 0 lint errors, typecheck clean, 230 tests passing, build success

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 07:15:06 +07:00

325 lines
12 KiB
TypeScript

'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
} from '@/lib/analytics-api';
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
const CITY = 'Ho Chi Minh';
const 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`;
}
interface StatCardProps {
title: string;
value: string;
description?: string;
trend?: number | null;
}
function StatCard({ title, value, description, trend }: StatCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardHeader>
{(description || trend != null) && (
<CardContent>
<div className="flex items-center gap-2">
{trend != null && (
<span
className={`text-xs font-medium ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}
>
{trend >= 0 ? '+' : ''}
{trend.toFixed(1)}%
</span>
)}
{description && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</div>
</CardContent>
)}
</Card>
);
}
export default function DashboardPage() {
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [listings, setListings] = useState<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
analyticsApi.getMarketReport(CITY, PERIOD).catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi.getHeatmap(CITY, PERIOD).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
listingsApi.search({ page: 1, limit: 6 }).catch(() => null),
])
.then(([report, heatmapData, listingsResult]) => {
setMarketReport(report.districts);
setHeatmap(heatmapData.dataPoints);
setListings(listingsResult);
})
.finally(() => setLoading(false));
}, []);
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgYoy =
marketReport.filter((d) => d.yoyChange != null).length > 0
? marketReport
.filter((d) => d.yoyChange != null)
.reduce((sum, d) => sum + (d.yoyChange ?? 0), 0) /
marketReport.filter((d) => d.yoyChange != null).length
: null;
const myListingsCount = listings?.total ?? 0;
const totalViews = listings?.data.reduce((s, l) => s + l.viewCount, 0) ?? 0;
const totalInquiries = listings?.data.reduce((s, l) => s + l.inquiryCount, 0) ?? 0;
const chartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.slice(0, 8)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Bang dieu khien</h1>
<p className="mt-2 text-muted-foreground">
Tong quan thi truong va tin dang cua ban
</p>
</div>
<Link href="/listings/new">
<Button>Dang tin moi</Button>
</Link>
</div>
{/* Stats overview */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Tin dang cua toi"
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
description="Tong so tin da dang"
/>
<StatCard
title="Luot xem"
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
description="Tren tat ca tin dang"
/>
<StatCard
title="Lien he"
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
description="Yeu cau tu khach hang"
/>
<StatCard
title="Gia TB thi truong"
value={loading ? '...' : formatPriceM2(avgPriceM2)}
trend={avgYoy}
description="YoY"
/>
</div>
{/* Market overview + quick stats */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Price chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg">Gia trung binh theo quan</CardTitle>
<CardDescription>{CITY} - {PERIOD} (trieu VND/m2)</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : chartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chua co du lieu
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="district"
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 12 }} className="fill-muted-foreground" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
formatter={(value) => [`${value} tr/m2`, 'Gia']}
/>
<Bar dataKey="Gia/m2" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Market summary */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Thi truong {CITY}</CardTitle>
<CardDescription>Chi so chinh - {PERIOD}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tong tin dang</span>
<span className="font-semibold">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Gia TB/m2</span>
<span className="font-semibold">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ngay TB de ban</span>
<span className="font-semibold">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">So quan</span>
<span className="font-semibold">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</span>
</div>
<div className="pt-2">
<Link href="/analytics">
<Button variant="outline" size="sm" className="w-full">
Xem phan tich chi tiet
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
{/* Recent listings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Tin dang gan day</CardTitle>
<CardDescription>Danh sach tin dang moi nhat cua ban</CardDescription>
</div>
<Link href="/listings">
<Button variant="outline" size="sm">
Xem tat ca
</Button>
</Link>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : !listings || listings.data.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
<p>Chua co tin dang nao</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Dang tin dau tien
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{listings.data.slice(0, 5).map((listing) => (
<Link
key={listing.id}
href={`/listings/${listing.id}`}
className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent"
>
<div className="relative h-12 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="64px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{listing.property.title}</p>
<p className="text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">
{formatPrice(listing.priceVND)}
</p>
<ListingStatusBadge status={listing.status} />
</div>
<div className="hidden sm:flex sm:gap-3 sm:text-sm sm:text-muted-foreground">
<span>{listing.viewCount} luot xem</span>
<span>{listing.inquiryCount} lien he</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}