feat(web): build agent dashboard with analytics charts and listing management
- Dashboard overview: stats cards (listings, views, inquiries, market avg price), Recharts bar chart for district pricing, recent listings feed with engagement metrics - Analytics page: tabbed layout (overview/trends/districts), interactive bar chart for district comparison, line chart for price trend over quarters with dual Y-axis, clickable heatmap cards - Listings management: grid/table view toggle, status filter, stats summary cards, table view with thumbnails and engagement data Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,15 +3,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
analyticsApi,
|
||||
type MarketReportDistrict,
|
||||
type HeatmapDataPoint,
|
||||
type DistrictStats,
|
||||
type PriceTrendPoint,
|
||||
} from '@/lib/analytics-api';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
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);
|
||||
@@ -29,19 +44,26 @@ 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
|
||||
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 [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(() => {
|
||||
@@ -49,30 +71,73 @@ export default function AnalyticsPage() {
|
||||
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[] })),
|
||||
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('Khong the tai du lieu phan tich'))
|
||||
.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 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 dang': p.totalListings,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Phan tich thi truong</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
@@ -93,163 +158,375 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-700">{error}</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Tong quan</TabsTrigger>
|
||||
<TabsTrigger value="trends">Xu huong gia</TabsTrigger>
|
||||
<TabsTrigger value="districts">Chi tiet quan</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 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>
|
||||
{/* 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">Gia trung binh theo quan</CardTitle>
|
||||
<CardDescription>Trieu VND/m2 tai {city}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Dang tai...
|
||||
</div>
|
||||
</div>
|
||||
) : barChartData.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Chua co du lieu
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={barChartData}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
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, name) => [
|
||||
name === 'price' ? `${value} tr/m2` : value,
|
||||
name === 'price' ? 'Gia' : 'Tin dang',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="price" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Heatmap - Card Grid */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Ban do gia theo quan</CardTitle>
|
||||
<CardDescription>So sanh gia trung binh/m2 tai {city}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Dang tai...
|
||||
</div>
|
||||
) : heatmap.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Chua co du lieu
|
||||
</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 dang
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Xu huong gia - {trendDistrict || 'Chon quan'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Bien dong gia trung binh/m2 qua cac quy (Can ho)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendLoading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Dang tai...
|
||||
</div>
|
||||
) : trendChartData.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Chua co du lieu xu huong
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart
|
||||
data={trendChartData}
|
||||
margin={{ top: 5, right: 30, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
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, name) => [
|
||||
name === 'Gia/m2' ? `${value} tr/m2` : value,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="Gia/m2"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="Tin dang"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="5 5"
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</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">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 Cards */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user