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>
304 lines
9.9 KiB
TypeScript
304 lines
9.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|