- Rewrite prisma/seed.ts to populate all 27 models with realistic Vietnamese real estate data (8 users with login, 10 properties, 10 listings, orders, payments, reviews, notifications, etc.) - Replace all emoji icons with Lucide React SVG icons across frontend for consistent rendering, sizing, and accessibility - Redesign dashboard nav: grouped sidebar with section headers, primary/secondary split on desktop, icon-only secondary items - Replace language switcher flag emoji with Globe icon - Replace SVG theme toggle with Lucide Moon/Sun icons - Fix API startup: graceful fallback for Sentry profiling, Google OAuth, and Zalo OAuth when credentials are not configured - Relax rate limiting in development mode (10k req/min) - Fix listings API to include media[] array in search response - Add optional chaining for property.media across frontend components - Update OAuth strategy tests to match graceful fallback behavior Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import dynamic from 'next/dynamic';
|
|
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
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 { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
|
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
|
|
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
|
import { staticBlurDataURL } from '@/lib/image-blur';
|
|
|
|
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 CITY = 'Ho Chi Minh';
|
|
const PERIOD = '2026-Q1';
|
|
|
|
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 { data: reportData, isLoading: reportLoading } = useMarketReport(CITY, PERIOD);
|
|
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(CITY, PERIOD);
|
|
const { data: listings, isLoading: listingsLoading } = useListingsSearch({ page: 1, limit: 6 });
|
|
|
|
const loading = reportLoading || heatmapLoading || listingsLoading;
|
|
const marketReport = reportData?.districts ?? [];
|
|
const heatmap = heatmapData?.dataPoints ?? [];
|
|
|
|
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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold sm:text-3xl">Bảng điều khiển</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Tổng quan thị trường và tin đăng của bạn
|
|
</p>
|
|
</div>
|
|
<Link href="/listings/new">
|
|
<Button>Đăng tin mới</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Stats overview */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard
|
|
title="Tin đăng của tôi"
|
|
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
|
|
description="Tổng số tin đã đăng"
|
|
/>
|
|
<StatCard
|
|
title="Lượt xem"
|
|
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
|
|
description="Trên tất cả tin đăng"
|
|
/>
|
|
<StatCard
|
|
title="Liên hệ"
|
|
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
|
|
description="Yêu cầu từ khách hàng"
|
|
/>
|
|
<StatCard
|
|
title="Giá TB thị trường"
|
|
value={loading ? '...' : formatPricePerM2(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">Giá trung bình theo quận</CardTitle>
|
|
<CardDescription>{CITY} - {PERIOD} (triệu VND/m²)</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
Đang tải...
|
|
</div>
|
|
) : chartData.length === 0 ? (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
Chưa có dữ liệu
|
|
</div>
|
|
) : (
|
|
<DistrictBarChart
|
|
data={chartData}
|
|
height={280}
|
|
dataKey="Gia/m2"
|
|
tooltipFormatter={(value) => [`${value} tr/m²`, 'Giá']}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Market summary */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Thị trường {CITY}</CardTitle>
|
|
<CardDescription>Chỉ số chính - {PERIOD}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Tổng tin đăng</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">Giá TB/m²</span>
|
|
<span className="font-semibold">
|
|
{loading ? '...' : formatPricePerM2(avgPriceM2)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Ngày TB để bán</span>
|
|
<span className="font-semibold">
|
|
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Số quận</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 phân tích chi tiết
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Recent listings */}
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">Tin đăng gần đây</CardTitle>
|
|
<CardDescription>Danh sách tin đăng mới nhất của bạn</CardDescription>
|
|
</div>
|
|
<Link href="/listings">
|
|
<Button variant="outline" size="sm">
|
|
Xem tất cả
|
|
</Button>
|
|
</Link>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
|
Đang tải...
|
|
</div>
|
|
) : !listings || listings.data.length === 0 ? (
|
|
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
|
|
<p>Chưa có tin đăng nào</p>
|
|
<Link href="/listings/new" className="mt-2">
|
|
<Button variant="outline" size="sm">
|
|
Đăng tin đầu tiên
|
|
</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) > 0 ? (
|
|
<Image
|
|
src={listing.property.media![0]?.url ?? ''}
|
|
alt={listing.property.title}
|
|
fill
|
|
sizes="64px"
|
|
className="object-cover"
|
|
placeholder="blur"
|
|
blurDataURL={staticBlurDataURL()}
|
|
/>
|
|
) : (
|
|
<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} lượt xem</span>
|
|
<span>{listing.inquiryCount} liên hệ</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|