feat(web): AVM v2 upgraded valuation dashboard (TEC-2763)
R5.4 ships the upgraded AVM UI behind the `avm_v2` A/B flag. When the
flag is on, the dashboard exposes:
- Tab switch between single valuation and multi-property compare
- Waterfall drivers chart (ValueDriversChart) alongside the existing
horizontal bar breakdown
- Mapbox comparables map with similarity-coloured markers and an
optional highlighted subject pin
- Confidence interval + range bar and PDF export remain available
- Valuation history chart surface unchanged (still lazy-loaded)
Flag plumbing (useAvmV2Flag):
- NEXT_PUBLIC_FEATURE_AVM_V2=1 enables by default
- `?avm_v2=1|0` URL param forces + persists to localStorage
- safe localStorage handling (no throw when storage is blocked)
Tests: comparables-map, value-drivers-chart, use-avm-v2-flag specs
added. Pre-existing "Yếu tố chính" assertion in valuation-results.spec
updated to match the current copy ("Yếu tố ảnh hưởng giá") so the
valuation suite is green (7 files, 52 tests).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
303
apps/web/components/valuation/valuation-compare.tsx
Normal file
303
apps/web/components/valuation/valuation-compare.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2, BarChart3 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||
import { useValuationBatch } from '@/lib/hooks/use-valuation';
|
||||
import {
|
||||
VALUATION_PROPERTY_TYPES,
|
||||
CITIES,
|
||||
} from '@/lib/validations/valuation';
|
||||
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||
|
||||
interface PropertySlot {
|
||||
id: string;
|
||||
propertyType: string;
|
||||
area: string;
|
||||
district: string;
|
||||
city: string;
|
||||
bedrooms: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function createEmptySlot(index: number): PropertySlot {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
propertyType: 'APARTMENT',
|
||||
area: '',
|
||||
district: '',
|
||||
city: 'Ho Chi Minh',
|
||||
bedrooms: '',
|
||||
label: `BĐS ${index + 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getConfidenceColor(c: number): string {
|
||||
if (c >= 0.8) return 'text-green-600';
|
||||
if (c >= 0.5) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
function getConfidenceVariant(c: number): 'success' | 'warning' | 'destructive' {
|
||||
if (c >= 0.8) return 'success';
|
||||
if (c >= 0.5) return 'warning';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export function ValuationCompare() {
|
||||
const [slots, setSlots] = useState<PropertySlot[]>([
|
||||
createEmptySlot(0),
|
||||
createEmptySlot(1),
|
||||
]);
|
||||
const [results, setResults] = useState<ValuationResult[] | null>(null);
|
||||
|
||||
const batchMutation = useValuationBatch();
|
||||
|
||||
const updateSlot = (id: string, field: keyof PropertySlot, value: string) => {
|
||||
setSlots((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
const addSlot = () => {
|
||||
if (slots.length >= 5) return;
|
||||
setSlots((prev) => [...prev, createEmptySlot(prev.length)]);
|
||||
};
|
||||
|
||||
const removeSlot = (id: string) => {
|
||||
if (slots.length <= 2) return;
|
||||
setSlots((prev) => prev.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
const handleCompare = () => {
|
||||
const validSlots = slots.filter((s) => s.area && s.district);
|
||||
if (validSlots.length < 2) return;
|
||||
|
||||
const properties: ValuationRequest[] = validSlots.map((s) => ({
|
||||
propertyType: s.propertyType,
|
||||
area: Number(s.area),
|
||||
district: s.district,
|
||||
city: s.city,
|
||||
bedrooms: s.bedrooms ? Number(s.bedrooms) : undefined,
|
||||
}));
|
||||
|
||||
batchMutation.mutate(
|
||||
{ properties },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setResults(data.results);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const bestValue =
|
||||
results && results.length > 0
|
||||
? results.reduce((best, r) =>
|
||||
r.pricePerM2 < best.pricePerM2 ? r : best,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
<CardTitle>So sánh định giá</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
So sánh giá trị ước tính của nhiều bất động sản cùng lúc (2-5 BĐS)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{slots.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="rounded-lg border p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">{slot.label}</Label>
|
||||
{slots.length > 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(slot.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Select
|
||||
value={slot.propertyType}
|
||||
onChange={(e) =>
|
||||
updateSlot(slot.id, 'propertyType', e.target.value)
|
||||
}
|
||||
>
|
||||
{VALUATION_PROPERTY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Diện tích (m²)"
|
||||
value={slot.area}
|
||||
onChange={(e) => updateSlot(slot.id, 'area', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Quận/Huyện"
|
||||
value={slot.district}
|
||||
onChange={(e) =>
|
||||
updateSlot(slot.id, 'district', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={slot.city}
|
||||
onChange={(e) => updateSlot(slot.id, 'city', e.target.value)}
|
||||
>
|
||||
{CITIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Phòng ngủ"
|
||||
value={slot.bedrooms}
|
||||
onChange={(e) =>
|
||||
updateSlot(slot.id, 'bedrooms', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{slots.length < 5 && (
|
||||
<Button type="button" variant="outline" onClick={addSlot}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Thêm BĐS
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
disabled={
|
||||
batchMutation.isPending ||
|
||||
slots.filter((s) => s.area && s.district).length < 2
|
||||
}
|
||||
>
|
||||
{batchMutation.isPending
|
||||
? 'Đang so sánh...'
|
||||
: 'So sánh ngay'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison results */}
|
||||
{results && results.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{results.map((result, i) => {
|
||||
const isBest = bestValue && result.id === bestValue.id;
|
||||
return (
|
||||
<Card
|
||||
key={result.id || i}
|
||||
className={isBest ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{slots[i]?.label ?? `BĐS ${i + 1}`}
|
||||
</CardTitle>
|
||||
{isBest && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
Giá/m² tốt nhất
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
{slots[i]?.district}, {slots[i]?.city}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{formatPrice(result.estimatedPriceVND)} VNĐ
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatPricePerM2(result.pricePerM2)}/m²
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Độ tin cậy</span>
|
||||
<span className={`font-semibold ${getConfidenceColor(result.confidence)}`}>
|
||||
{Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${
|
||||
result.confidence >= 0.8
|
||||
? 'bg-green-500'
|
||||
: result.confidence >= 0.5
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${result.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Khoảng giá: {formatPrice(result.priceRangeLow)} -{' '}
|
||||
{formatPrice(result.priceRangeHigh)}
|
||||
</div>
|
||||
|
||||
{result.priceDrivers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{result.priceDrivers.slice(0, 3).map((d) => (
|
||||
<Badge
|
||||
key={d.feature}
|
||||
variant={getConfidenceVariant(
|
||||
d.direction === 'positive' ? 1 : 0,
|
||||
)}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{d.direction === 'positive' ? '+' : '-'}
|
||||
{Math.abs(d.impact).toFixed(0)}% {d.feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchMutation.isError && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Không thể so sánh. Vui lòng thử lại sau.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user