Files
goodgo-platform/apps/web/components/valuation/valuation-compare.tsx
Ho Ngoc Hai 5d4ecdeb2f 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>
2026-04-18 15:05:46 +07:00

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>
);
}