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:
Ho Ngoc Hai
2026-04-18 15:05:46 +07:00
parent e18390ead9
commit 5d4ecdeb2f
9 changed files with 1135 additions and 49 deletions

View File

@@ -2,12 +2,17 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { ComparablesMap } from '@/components/valuation/comparables-map';
import { ComparablesTable } from '@/components/valuation/comparables-table'; import { ComparablesTable } from '@/components/valuation/comparables-table';
import { ExportPdfButton } from '@/components/valuation/export-pdf-button'; import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
import { MarketContextCard } from '@/components/valuation/market-context-card'; import { MarketContextCard } from '@/components/valuation/market-context-card';
import { ValuationCompare } from '@/components/valuation/valuation-compare';
import { ValuationForm } from '@/components/valuation/valuation-form'; import { ValuationForm } from '@/components/valuation/valuation-form';
import { ValuationHistory } from '@/components/valuation/valuation-history'; import { ValuationHistory } from '@/components/valuation/valuation-history';
import { ValuationResults } from '@/components/valuation/valuation-results'; import { ValuationResults } from '@/components/valuation/valuation-results';
import { ValueDriversChart } from '@/components/valuation/value-drivers-chart';
import { useAvmV2Flag } from '@/lib/hooks/use-avm-v2-flag';
import { import {
useValuationPredict, useValuationPredict,
useValuationHistory, useValuationHistory,
@@ -15,7 +20,6 @@ import {
} from '@/lib/hooks/use-valuation'; } from '@/lib/hooks/use-valuation';
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
// Lazy-load chart component (uses Recharts, no SSR)
const ValuationHistoryChart = dynamic( const ValuationHistoryChart = dynamic(
() => () =>
import('@/components/valuation/valuation-history-chart').then( import('@/components/valuation/valuation-history-chart').then(
@@ -31,9 +35,13 @@ const ValuationHistoryChart = dynamic(
}, },
); );
type ViewMode = 'single' | 'compare';
export default function ValuationPage() { export default function ValuationPage() {
const avmV2 = useAvmV2Flag();
const [historyPage, setHistoryPage] = useState(1); const [historyPage, setHistoryPage] = useState(1);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('single');
const predictMutation = useValuationPredict(); const predictMutation = useValuationPredict();
const { data: historyData, isLoading: historyLoading } = const { data: historyData, isLoading: historyLoading } =
@@ -54,15 +62,21 @@ export default function ValuationPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Page header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold sm:text-3xl">Đnh giá AI</h1> <h1 className="text-2xl font-bold sm:text-3xl">Đnh giá AI</h1>
{avmV2 && (
<Badge variant="success" className="text-xs" data-testid="avm-v2-badge">
AVM v2
</Badge>
)}
</div>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Sử dụng AI đ ưc tính giá trị bất đng sản dựa trên dữ liệu thị trường Sử dụng AI đ ưc tính giá trị bất đng sản dựa trên dữ liệu thị trường
</p> </p>
</div> </div>
{currentResult && ( {currentResult && viewMode === 'single' && (
<ExportPdfButton <ExportPdfButton
targetSelector="#valuation-results" targetSelector="#valuation-results"
filename={`dinh-gia-${currentResult.id}`} filename={`dinh-gia-${currentResult.id}`}
@@ -70,8 +84,47 @@ export default function ValuationPage() {
)} )}
</div> </div>
{avmV2 && (
<div
className="inline-flex rounded-lg border bg-muted/40 p-1"
role="tablist"
aria-label="Chế độ định giá"
>
<button
type="button"
role="tab"
aria-selected={viewMode === 'single'}
data-testid="avm-v2-tab-single"
className={`rounded-md px-4 py-1.5 text-sm font-medium transition ${
viewMode === 'single'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => setViewMode('single')}
>
Đnh giá đơn
</button>
<button
type="button"
role="tab"
aria-selected={viewMode === 'compare'}
data-testid="avm-v2-tab-compare"
className={`rounded-md px-4 py-1.5 text-sm font-medium transition ${
viewMode === 'compare'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => setViewMode('compare')}
>
So sánh nhiều BĐS
</button>
</div>
)}
{viewMode === 'compare' && avmV2 ? (
<ValuationCompare />
) : (
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Form + Results (left 2 cols) */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
<ValuationForm <ValuationForm
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -86,20 +139,26 @@ export default function ValuationPage() {
{currentResult && ( {currentResult && (
<> <>
{/* Main results with confidence badge + driver charts */}
<ValuationResults result={currentResult} /> <ValuationResults result={currentResult} />
{/* Comparables table (TanStack Table) */} {avmV2 && currentResult.priceDrivers.length > 0 && (
<ValueDriversChart drivers={currentResult.priceDrivers} />
)}
{currentResult.comparables.length > 0 && ( {currentResult.comparables.length > 0 && (
<ComparablesTable comparables={currentResult.comparables} /> <ComparablesTable comparables={currentResult.comparables} />
)} )}
{/* Market context card */} {avmV2 && currentResult.comparables.length > 0 && (
<ComparablesMap
comparables={currentResult.comparables}
/>
)}
{currentResult.marketContext && ( {currentResult.marketContext && (
<MarketContextCard context={currentResult.marketContext} /> <MarketContextCard context={currentResult.marketContext} />
)} )}
{/* Valuation history chart */}
{currentResult.valuationHistory && {currentResult.valuationHistory &&
currentResult.valuationHistory.length >= 2 && ( currentResult.valuationHistory.length >= 2 && (
<ValuationHistoryChart data={currentResult.valuationHistory} /> <ValuationHistoryChart data={currentResult.valuationHistory} />
@@ -108,7 +167,6 @@ export default function ValuationPage() {
)} )}
</div> </div>
{/* History sidebar (right col) */}
<div> <div>
<ValuationHistory <ValuationHistory
items={historyData?.data ?? []} items={historyData?.data ?? []}
@@ -120,6 +178,7 @@ export default function ValuationPage() {
/> />
</div> </div>
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,149 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type { ValuationComparable } from '@/lib/valuation-api';
import { ComparablesMap } from '../comparables-map';
// Mapbox GL does not run cleanly in jsdom — mock with a minimal stand-in
// that records addTo calls so we can assert marker count.
const markerAddTo = vi.fn();
const mapAddControl = vi.fn();
const mapFitBounds = vi.fn();
const mapFlyTo = vi.fn();
const mapRemove = vi.fn();
vi.mock('mapbox-gl', () => {
class MockMap {
addControl = mapAddControl;
fitBounds = mapFitBounds;
flyTo = mapFlyTo;
remove = mapRemove;
}
class MockNavigationControl {}
class MockAttributionControl {}
class MockMarker {
setLngLat() {
return this;
}
setPopup() {
return this;
}
addTo() {
markerAddTo();
return this;
}
remove() {
// noop
}
}
class MockPopup {
setHTML() {
return this;
}
}
class MockLngLatBounds {
extend() {
return this;
}
isEmpty() {
return false;
}
}
return {
default: {
accessToken: '',
Map: MockMap,
NavigationControl: MockNavigationControl,
AttributionControl: MockAttributionControl,
Marker: MockMarker,
Popup: MockPopup,
LngLatBounds: MockLngLatBounds,
},
};
});
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
const sampleComparables: ValuationComparable[] = [
{
id: 'comp-1',
title: 'Căn hộ tương tự A',
address: '456 Nguyễn Hữu Thọ',
district: 'Quận 7',
priceVND: '4800000000',
areaM2: 78,
pricePerM2: 61_500_000,
similarity: 0.92,
latitude: 10.73,
longitude: 106.72,
},
{
id: 'comp-2',
title: 'Căn hộ tương tự B',
address: '789 Phạm Viết Chánh',
district: 'Bình Thạnh',
priceVND: '5200000000',
areaM2: 82,
pricePerM2: 63_400_000,
similarity: 0.7,
latitude: 10.8,
longitude: 106.7,
},
];
describe('ComparablesMap', () => {
beforeEach(() => {
markerAddTo.mockClear();
mapAddControl.mockClear();
mapFitBounds.mockClear();
mapFlyTo.mockClear();
mapRemove.mockClear();
process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'pk.test';
});
afterEach(() => {
delete (process.env as Record<string, string | undefined>)[
'NEXT_PUBLIC_MAPBOX_TOKEN'
];
});
it('renders header and descriptor', () => {
render(<ComparablesMap comparables={sampleComparables} />);
expect(screen.getByText('Bản đồ so sánh')).toBeInTheDocument();
expect(screen.getByText(/2 BĐS so sánh/)).toBeInTheDocument();
});
it('renders prompt when mapbox token is missing', () => {
delete (process.env as Record<string, string | undefined>)[
'NEXT_PUBLIC_MAPBOX_TOKEN'
];
render(<ComparablesMap comparables={sampleComparables} />);
expect(
screen.getByText(/Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN/),
).toBeInTheDocument();
});
it('shows empty state when no comparables have coordinates', () => {
const withoutCoords = sampleComparables.map(
({ latitude: _lat, longitude: _lng, ...rest }) => rest,
);
render(<ComparablesMap comparables={withoutCoords} />);
expect(
screen.getByText(/Không có toạ độ cho các BĐS so sánh/),
).toBeInTheDocument();
});
it('adds a marker for each geolocated comparable plus subject pin', () => {
render(
<ComparablesMap
comparables={sampleComparables}
subjectLatitude={10.77}
subjectLongitude={106.7}
/>,
);
expect(markerAddTo).toHaveBeenCalledTimes(3);
});
});

View File

@@ -64,7 +64,7 @@ describe('ValuationResults', () => {
it('renders price drivers section', () => { it('renders price drivers section', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText('Yếu tố chính')).toBeInTheDocument(); expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument(); expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument(); expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
}); });
@@ -82,7 +82,7 @@ describe('ValuationResults', () => {
it('hides drivers section when empty', () => { it('hides drivers section when empty', () => {
const noDrivers = { ...mockResult, priceDrivers: [] }; const noDrivers = { ...mockResult, priceDrivers: [] };
render(<ValuationResults result={noDrivers} />); render(<ValuationResults result={noDrivers} />);
expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument(); expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { PriceDriver } from '@/lib/valuation-api';
import { ValueDriversChart } from '../value-drivers-chart';
// Recharts uses ResizeObserver and SVG path measurements that jsdom does not
// implement. Stub ResponsiveContainer so child bars render in tests.
vi.mock('recharts', async () => {
const actual = (await vi.importActual('recharts')) as Record<string, unknown>;
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="chart-container" style={{ width: 800, height: 400 }}>
{children}
</div>
),
};
});
const drivers: PriceDriver[] = [
{ feature: 'area_m2', impact: 20, direction: 'positive' },
{ feature: 'building_age_years', impact: -8, direction: 'negative' },
{ feature: 'distance_to_cbd_km', impact: -4.5, direction: 'negative' },
];
describe('ValueDriversChart', () => {
it('renders header and description', () => {
render(<ValueDriversChart drivers={drivers} />);
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
expect(
screen.getByText(/Biểu đồ thác nước/),
).toBeInTheDocument();
});
it('renders nothing when drivers list is empty', () => {
const { container } = render(<ValueDriversChart drivers={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('renders chart container when drivers are provided', () => {
render(<ValueDriversChart drivers={drivers} />);
expect(screen.getByTestId('chart-container')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,230 @@
'use client';
/* eslint-disable import-x/no-named-as-default-member */
import mapboxgl from 'mapbox-gl';
import * as React from 'react';
import 'mapbox-gl/dist/mapbox-gl.css';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ValuationComparable } from '@/lib/valuation-api';
interface ComparablesMapProps {
comparables: ValuationComparable[];
subjectLatitude?: number;
subjectLongitude?: number;
className?: string;
}
const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231];
const DEFAULT_ZOOM = 11;
function similarityColor(sim: number): string {
if (sim >= 0.85) return '#16a34a';
if (sim >= 0.7) return '#eab308';
return '#dc2626';
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function ComparablesMap({
comparables,
subjectLatitude,
subjectLongitude,
className,
}: ComparablesMapProps) {
const mapContainerRef = React.useRef<HTMLDivElement>(null);
const mapRef = React.useRef<mapboxgl.Map | null>(null);
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
const geoComparables = React.useMemo(
() =>
comparables.filter(
(c) => c.latitude != null && c.longitude != null,
),
[comparables],
);
React.useEffect(() => {
if (!mapContainerRef.current) return;
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
if (!token) return;
mapboxgl.accessToken = token;
const map = new mapboxgl.Map({
container: mapContainerRef.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
attributionControl: false,
});
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.addControl(
new mapboxgl.AttributionControl({ compact: true }),
'bottom-right',
);
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
React.useEffect(() => {
const map = mapRef.current;
if (!map) return;
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
const bounds = new mapboxgl.LngLatBounds();
let extended = false;
if (subjectLatitude != null && subjectLongitude != null) {
const subjectEl = document.createElement('div');
subjectEl.setAttribute('data-testid', 'comparables-map-subject');
subjectEl.style.cssText = `
width: 22px;
height: 22px;
border-radius: 50%;
background: hsl(221.2, 83.2%, 53.3%);
border: 3px solid white;
box-shadow: 0 0 0 2px hsl(221.2, 83.2%, 53.3%), 0 2px 6px rgba(0,0,0,0.3);
`;
const marker = new mapboxgl.Marker({ element: subjectEl })
.setLngLat([subjectLongitude, subjectLatitude])
.addTo(map);
markersRef.current.push(marker);
bounds.extend([subjectLongitude, subjectLatitude]);
extended = true;
}
geoComparables.forEach((comp) => {
const color = similarityColor(comp.similarity);
const el = document.createElement('div');
el.setAttribute('data-testid', 'comparables-map-marker');
el.style.cssText = `
background: white;
border-radius: 8px;
padding: 4px 8px;
font-size: 11px;
font-weight: 600;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
white-space: nowrap;
cursor: pointer;
border-left: 3px solid ${color};
transition: transform 0.15s;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
`;
el.textContent = formatPrice(comp.priceVND);
el.addEventListener('mouseenter', () => {
el.style.transform = 'scale(1.08)';
});
el.addEventListener('mouseleave', () => {
el.style.transform = 'scale(1)';
});
const popup = new mapboxgl.Popup({
offset: 15,
maxWidth: '280px',
closeButton: false,
}).setHTML(
`<div style="font-family:system-ui,sans-serif;padding:4px 0;">
<p style="font-weight:600;font-size:13px;margin:0 0 4px;">${escapeHtml(comp.title)}</p>
<p style="font-size:12px;color:#666;margin:0 0 4px;">${escapeHtml(comp.address)}</p>
<p style="font-size:12px;margin:0 0 2px;">
<span style="font-weight:600;color:hsl(221.2,83.2%,53.3%);">${formatPrice(comp.priceVND)} VNĐ</span>
<span style="color:#666;margin-left:6px;">${formatPricePerM2(comp.pricePerM2)}</span>
</p>
<p style="font-size:12px;color:#666;margin:0;">
${comp.areaM2} m² · Tương đồng ${Math.round(comp.similarity * 100)}%
</p>
</div>`,
);
const marker = new mapboxgl.Marker({ element: el, anchor: 'left' })
.setLngLat([comp.longitude!, comp.latitude!])
.setPopup(popup)
.addTo(map);
markersRef.current.push(marker);
bounds.extend([comp.longitude!, comp.latitude!]);
extended = true;
});
if (!extended) return;
if (!bounds.isEmpty() && markersRef.current.length > 1) {
map.fitBounds(bounds, { padding: 60, maxZoom: 14 });
} else if (markersRef.current.length === 1) {
const first = geoComparables[0] ?? {
latitude: subjectLatitude,
longitude: subjectLongitude,
};
if (first.latitude != null && first.longitude != null) {
map.flyTo({ center: [first.longitude, first.latitude], zoom: 14 });
}
}
}, [geoComparables, subjectLatitude, subjectLongitude]);
const hasToken =
typeof process !== 'undefined' &&
Boolean(process.env['NEXT_PUBLIC_MAPBOX_TOKEN']);
const hasAnyGeo =
geoComparables.length > 0 ||
(subjectLatitude != null && subjectLongitude != null);
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bản đ so sánh</CardTitle>
<CardDescription>
Vị trí các bất đng sản tương tự đưc sử dụng trong hình AVM
</CardDescription>
</CardHeader>
<CardContent>
<div
className={`relative overflow-hidden rounded-lg border ${className || 'h-[360px] md:h-[420px]'}`}
>
<div ref={mapContainerRef} className="h-full w-full" />
{!hasToken && (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50 text-center text-sm text-muted-foreground">
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN đ hiển thị bản đ
</div>
)}
{hasToken && !hasAnyGeo && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/40 text-center text-sm text-muted-foreground">
Không toạ đ cho các BĐS so sánh
</div>
)}
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-2 py-1 text-xs text-muted-foreground shadow">
{geoComparables.length} BĐS so sánh
</div>
</div>
</CardContent>
</Card>
);
}

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

View File

@@ -0,0 +1,147 @@
'use client';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { PriceDriver } from '@/lib/valuation-api';
interface ValueDriversChartProps {
drivers: PriceDriver[];
}
const FEATURE_LABELS: Record<string, string> = {
area_m2: 'Diện tích',
avg_price_district_3m_vnd_m2: 'Giá TB khu vực',
property_type_encoded: 'Loại BĐS',
distance_to_cbd_km: 'Khoảng cách trung tâm',
renovation_score: 'Cải tạo',
building_age_years: 'Tuổi công trình',
has_legal_paper: 'Giấy tờ pháp lý',
distance_to_metro_km: 'Khoảng cách metro',
interior_quality: 'Nội thất',
price_momentum_30d: 'Đà tăng giá 30 ngày',
view_quality: 'Chất lượng view',
natural_light: 'Ánh sáng tự nhiên',
noise_level: 'Mức ồn',
flood_zone_risk: 'Nguy cơ ngập',
park_occupancy_rate: 'Tỉ lệ lấp đầy',
logistics_connectivity_score: 'Kết nối logistics',
industry_demand_index: 'Nhu cầu CN',
};
function getFeatureLabel(feature: string): string {
return FEATURE_LABELS[feature] || feature.replace(/_/g, ' ');
}
interface WaterfallItem {
name: string;
base: number;
value: number;
fill: string;
importance: number;
direction: 'positive' | 'negative';
}
function buildWaterfallData(drivers: PriceDriver[]): WaterfallItem[] {
const sorted = [...drivers].sort(
(a, b) => Math.abs(b.impact) - Math.abs(a.impact),
);
let cumulative = 0;
return sorted.map((driver) => {
const isPositive = driver.direction === 'positive';
const absImpact = Math.abs(driver.impact);
const item: WaterfallItem = {
name: getFeatureLabel(driver.feature),
base: isPositive ? cumulative : cumulative - absImpact,
value: absImpact,
fill: isPositive ? '#22c55e' : '#ef4444',
importance: absImpact,
direction: driver.direction,
};
cumulative += isPositive ? absImpact : -absImpact;
return item;
});
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: WaterfallItem }>;
}) {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-popover px-3 py-2 text-sm shadow-md">
<p className="font-medium">{data.name}</p>
<p className={data.direction === 'positive' ? 'text-green-600' : 'text-red-600'}>
{data.direction === 'positive' ? '+' : '-'}
{data.importance.toFixed(1)}%
</p>
</div>
);
}
export function ValueDriversChart({ drivers }: ValueDriversChartProps) {
if (drivers.length === 0) return null;
const data = buildWaterfallData(drivers);
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Yếu tố nh hưởng giá</CardTitle>
<CardDescription>
Biểu đ thác nước thể hiện mức nh hưởng của từng yếu tố
</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={Math.max(300, data.length * 44)}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<XAxis
type="number"
tickFormatter={(v: number) => `${v.toFixed(0)}%`}
domain={['dataMin', 'dataMax']}
/>
<YAxis
type="category"
dataKey="name"
width={150}
tick={{ fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine x={0} stroke="#888" strokeDasharray="3 3" />
{/* Invisible base bar for waterfall offset */}
<Bar dataKey="base" stackId="waterfall" fill="transparent" />
{/* Visible value bar */}
<Bar dataKey="value" stackId="waterfall" radius={[0, 4, 4, 0]}>
{data.map((entry, index) => (
<Cell key={index} fill={entry.fill} fillOpacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,99 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const LOCAL_KEY = 'goodgo:avm_v2';
function installMemoryStorage(): Storage {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
removeItem: (k) => {
store.delete(k);
},
setItem: (k, v) => {
store.set(k, String(v));
},
};
vi.stubGlobal('localStorage', storage);
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: storage,
});
return storage;
}
describe('useAvmV2Flag', () => {
let storage: Storage;
beforeEach(() => {
vi.resetModules();
storage = installMemoryStorage();
window.history.replaceState({}, '', '/');
delete (process.env as Record<string, string | undefined>)[
'NEXT_PUBLIC_FEATURE_AVM_V2'
];
});
afterEach(() => {
storage.clear();
vi.unstubAllGlobals();
});
it('returns false by default when env flag is not set', async () => {
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
const { result } = renderHook(() => useAvmV2Flag());
expect(result.current).toBe(false);
});
it('returns true when NEXT_PUBLIC_FEATURE_AVM_V2 is "1"', async () => {
process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = '1';
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
const { result } = renderHook(() => useAvmV2Flag());
expect(result.current).toBe(true);
});
it('returns true when NEXT_PUBLIC_FEATURE_AVM_V2 is "true"', async () => {
process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = 'true';
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
const { result } = renderHook(() => useAvmV2Flag());
expect(result.current).toBe(true);
});
it('query param ?avm_v2=1 forces on and persists to localStorage', async () => {
window.history.replaceState({}, '', '/?avm_v2=1');
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
const { result } = renderHook(() => useAvmV2Flag());
await act(async () => {
await Promise.resolve();
});
expect(result.current).toBe(true);
expect(storage.getItem(LOCAL_KEY)).toBe('1');
});
it('query param ?avm_v2=0 forces off and persists to localStorage', async () => {
process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = '1';
window.history.replaceState({}, '', '/?avm_v2=0');
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
const { result } = renderHook(() => useAvmV2Flag());
await act(async () => {
await Promise.resolve();
});
expect(result.current).toBe(false);
expect(storage.getItem(LOCAL_KEY)).toBe('0');
});
it('respects localStorage override over env default', async () => {
storage.setItem(LOCAL_KEY, '1');
const { useAvmV2Flag } = await import('../use-avm-v2-flag');
const { result } = renderHook(() => useAvmV2Flag());
await act(async () => {
await Promise.resolve();
});
expect(result.current).toBe(true);
});
});

View File

@@ -0,0 +1,55 @@
'use client';
import { useEffect, useState } from 'react';
const LOCAL_STORAGE_KEY = 'goodgo:avm_v2';
const QUERY_PARAM = 'avm_v2';
function readEnvDefault(): boolean {
const raw = process.env['NEXT_PUBLIC_FEATURE_AVM_V2'];
if (!raw) return false;
return raw === '1' || raw.toLowerCase() === 'true';
}
function readOverride(): boolean | null {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
const qp = params.get(QUERY_PARAM);
if (qp === '1' || qp === 'true') {
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, '1');
} catch {
// localStorage may be blocked — ignore
}
return true;
}
if (qp === '0' || qp === 'false') {
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, '0');
} catch {
// localStorage may be blocked — ignore
}
return false;
}
try {
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (stored === '1') return true;
if (stored === '0') return false;
} catch {
// ignore
}
return null;
}
export function useAvmV2Flag(): boolean {
const [enabled, setEnabled] = useState<boolean>(readEnvDefault());
useEffect(() => {
const override = readOverride();
setEnabled(override ?? readEnvDefault());
}, []);
return enabled;
}