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:
110
apps/web/components/valuation/valuation-history-chart.tsx
Normal file
110
apps/web/components/valuation/valuation-history-chart.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import type { ValuationHistoryPoint } from '@/lib/valuation-api';
|
||||
|
||||
interface ValuationHistoryChartProps {
|
||||
data: ValuationHistoryPoint[];
|
||||
}
|
||||
|
||||
function formatChartDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('vi-VN', { month: 'short', year: '2-digit' });
|
||||
}
|
||||
|
||||
function formatTooltipDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function ValuationHistoryChart({ data }: ValuationHistoryChartProps) {
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const chartData = data.map((point) => ({
|
||||
date: point.date,
|
||||
price: point.estimatedPriceVND,
|
||||
confidence: Math.round(point.confidence * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Lịch sử định giá</CardTitle>
|
||||
<CardDescription>
|
||||
Biến động giá ước tính theo thời gian
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="priceGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatChartDate}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(val: number) => formatPrice(val)}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={12}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
labelFormatter={(label) =>
|
||||
typeof label === 'string' ? formatTooltipDate(label) : label
|
||||
}
|
||||
formatter={(value, name) => {
|
||||
const num = Number(value);
|
||||
if (name === 'price') return [formatPrice(num) + ' VNĐ', 'Giá ước tính'];
|
||||
if (name === 'confidence') return [`${num}%`, 'Độ tin cậy'];
|
||||
return [String(value), name];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
fill="url(#priceGradient)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user