feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade
Add batch valuation (POST /analytics/valuation/batch, max 50 properties), valuation comparison (POST /analytics/valuation/compare, 2-5 properties), and history endpoint (GET /analytics/valuation/history/:propertyId) with confidence explanation helper. Frontend: enhanced valuation form with project autocomplete and deep analysis toggle, results with confidence badges and price range visualization, comparables table, history chart, market context card, and PDF export. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
95
apps/web/components/valuation/market-context-card.tsx
Normal file
95
apps/web/components/valuation/market-context-card.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Building,
|
||||
CalendarDays,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||
import type { MarketContext } from '@/lib/valuation-api';
|
||||
|
||||
interface MarketContextCardProps {
|
||||
context: MarketContext;
|
||||
}
|
||||
|
||||
export function MarketContextCard({ context }: MarketContextCardProps) {
|
||||
const isGrowthPositive = context.priceGrowthYoY >= 0;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Giá trung bình/m²',
|
||||
value: formatPricePerM2(context.avgPricePerM2),
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
label: 'Giá trung vị',
|
||||
value: formatPrice(context.medianPrice),
|
||||
icon: Warehouse,
|
||||
},
|
||||
{
|
||||
label: 'Tăng trưởng YoY',
|
||||
value: `${isGrowthPositive ? '+' : ''}${context.priceGrowthYoY.toFixed(1)}%`,
|
||||
icon: isGrowthPositive ? TrendingUp : TrendingDown,
|
||||
color: isGrowthPositive ? 'text-green-600' : 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: 'Chỉ số nhu cầu',
|
||||
value: `${context.demandIndex.toFixed(0)}/100`,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Nguồn cung',
|
||||
value: `${context.supplyCount.toLocaleString('vi-VN')} BĐS`,
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
label: 'Thời gian bán TB',
|
||||
value: `${context.avgDaysOnMarket} ngày`,
|
||||
icon: CalendarDays,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bối cảnh thị trường</CardTitle>
|
||||
<CardDescription>
|
||||
{context.district}, {context.city} — {context.period}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||
<p className={`text-sm font-semibold ${stat.color ?? ''}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user