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:
@@ -1,138 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { ValuationResult } from '@/lib/valuation-api';
|
||||
import { ShieldCheck, ShieldAlert, ShieldQuestion, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||
import type { ConfidenceExplanation, ValuationResult } from '@/lib/valuation-api';
|
||||
|
||||
interface ValuationResultsProps {
|
||||
result: ValuationResult;
|
||||
}
|
||||
|
||||
function formatPrice(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} tỷ`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
||||
return num.toLocaleString('vi-VN');
|
||||
function getConfidenceBadge(confidence: number): {
|
||||
label: string;
|
||||
variant: 'success' | 'warning' | 'destructive';
|
||||
icon: typeof ShieldCheck;
|
||||
} {
|
||||
if (confidence >= 0.8) {
|
||||
return { label: 'Độ tin cậy cao', variant: 'success', icon: ShieldCheck };
|
||||
}
|
||||
if (confidence >= 0.5) {
|
||||
return { label: 'Độ tin cậy trung bình', variant: 'warning', icon: ShieldAlert };
|
||||
}
|
||||
return { label: 'Độ tin cậy thấp', variant: 'destructive', icon: ShieldQuestion };
|
||||
}
|
||||
|
||||
function formatPriceM2(price: number): string {
|
||||
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
|
||||
return `${price.toLocaleString('vi-VN')} đ/m²`;
|
||||
function ConfidenceDetail({ explanation }: { explanation: ConfidenceExplanation }) {
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{explanation.summary}</p>
|
||||
<div className="space-y-2">
|
||||
{explanation.factors.map((factor) => (
|
||||
<div key={factor.factor} className="flex items-start gap-2 text-sm">
|
||||
<span
|
||||
className={`mt-0.5 ${
|
||||
factor.contribution === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{factor.contribution === 'positive' ? (
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<TrendingDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-medium">{factor.factor}</span>
|
||||
<span className="text-muted-foreground"> — {factor.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceRangeBar({
|
||||
low,
|
||||
high,
|
||||
estimated,
|
||||
}: {
|
||||
low: number;
|
||||
high: number;
|
||||
estimated: number;
|
||||
}) {
|
||||
const range = high - low;
|
||||
const position = range > 0 ? ((estimated - low) / range) * 100 : 50;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="relative h-3 rounded-full bg-gradient-to-r from-red-200 via-yellow-200 to-green-200">
|
||||
<div
|
||||
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
|
||||
style={{ left: `${Math.max(5, Math.min(95, position))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatPrice(low)}</span>
|
||||
<span className="font-medium text-foreground">{formatPrice(estimated)}</span>
|
||||
<span>{formatPrice(high)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValuationResults({ result }: ValuationResultsProps) {
|
||||
const confidencePct = Math.round(result.confidence * 100);
|
||||
const badge = getConfidenceBadge(result.confidence);
|
||||
const BadgeIcon = badge.icon;
|
||||
|
||||
// Sort drivers by absolute impact for chart display
|
||||
const sortedDrivers = [...result.priceDrivers].sort(
|
||||
(a, b) => Math.abs(b.impact) - Math.abs(a.impact),
|
||||
);
|
||||
const maxImpact = Math.max(...sortedDrivers.map((d) => Math.abs(d.impact)), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Main estimate */}
|
||||
<div id="valuation-results" className="space-y-6">
|
||||
{/* Main estimate card */}
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Giá ước tính bởi AI</CardDescription>
|
||||
<CardTitle className="text-3xl text-primary">
|
||||
{formatPrice(result.estimatedPriceVND)} VNĐ
|
||||
</CardTitle>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardDescription>Giá ước tính bởi AI</CardDescription>
|
||||
<CardTitle className="text-3xl text-primary sm:text-4xl">
|
||||
{formatPrice(result.estimatedPriceVND)} VNĐ
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge variant={badge.variant} className="flex items-center gap-1">
|
||||
<BadgeIcon className="h-3.5 w-3.5" />
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Độ tin cậy</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div className="h-2.5 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-primary transition-all"
|
||||
className={`h-2.5 rounded-full transition-all ${
|
||||
confidencePct >= 80
|
||||
? 'bg-green-500'
|
||||
: confidencePct >= 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${confidencePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{confidencePct}%</span>
|
||||
<span className="text-sm font-semibold">{confidencePct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Giá/m²</p>
|
||||
<p className="mt-1 text-lg font-semibold">{formatPriceM2(result.pricePerM2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Khoảng giá</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{formatPrice(result.priceRangeLow)} – {formatPrice(result.priceRangeHigh)}
|
||||
{formatPricePerM2(result.pricePerM2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price range bar */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Khoảng giá</p>
|
||||
<PriceRangeBar
|
||||
low={result.priceRangeLow}
|
||||
high={result.priceRangeHigh}
|
||||
estimated={result.estimatedPriceVND}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confidence explanation (deep analysis) */}
|
||||
{result.confidenceExplanation && (
|
||||
<ConfidenceDetail explanation={result.confidenceExplanation} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Price drivers */}
|
||||
{result.priceDrivers.length > 0 && (
|
||||
{/* Value drivers — horizontal bar chart */}
|
||||
{sortedDrivers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Yếu tố ảnh hưởng giá</CardTitle>
|
||||
<CardDescription>Các yếu tố chính tác động đến giá trị bất động sản</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{result.priceDrivers.map((driver) => (
|
||||
<div key={driver.feature} className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
driver.direction === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{driver.direction === 'positive' ? '+' : '-'}
|
||||
{Math.abs(driver.impact).toFixed(1)}%
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{driver.feature}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${
|
||||
driver.direction === 'positive' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(Math.abs(driver.impact), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Comparables */}
|
||||
{result.comparables.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bất động sản tương tự</CardTitle>
|
||||
<CardDescription>
|
||||
{result.comparables.length} bất động sản có đặc điểm tương tự trong khu vực
|
||||
Các yếu tố chính tác động đến giá trị bất động sản
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{result.comparables.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center gap-4 rounded-lg border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{comp.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{comp.district} · {comp.areaM2} m²
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{sortedDrivers.map((driver) => {
|
||||
const barWidth = (Math.abs(driver.impact) / maxImpact) * 100;
|
||||
const isPositive = driver.direction === 'positive';
|
||||
|
||||
return (
|
||||
<div key={driver.feature} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{driver.feature}</span>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{isPositive ? '+' : '-'}
|
||||
{Math.abs(driver.impact).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-6 rounded bg-muted">
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 flex items-center rounded transition-all ${
|
||||
isPositive
|
||||
? 'bg-green-500/20 text-green-700'
|
||||
: 'bg-red-500/20 text-red-700'
|
||||
}`}
|
||||
style={{ width: `${Math.max(barWidth, 8)}%` }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
isPositive ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: '100%', opacity: 0.7 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{driver.explanation && (
|
||||
<p className="text-xs text-muted-foreground">{driver.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-primary">{formatPrice(Number(comp.priceVND))}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatPriceM2(comp.pricePerM2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<span className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
|
||||
{Math.round(comp.similarity * 100)}% tương tự
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user