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:
@@ -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>
|
||||||
<h1 className="text-2xl font-bold sm:text-3xl">Định giá AI</h1>
|
<div className="flex items-center gap-2">
|
||||||
|
<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,56 +84,101 @@ export default function ValuationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
{avmV2 && (
|
||||||
{/* Form + Results (left 2 cols) */}
|
<div
|
||||||
<div className="space-y-6 lg:col-span-2">
|
className="inline-flex rounded-lg border bg-muted/40 p-1"
|
||||||
<ValuationForm
|
role="tablist"
|
||||||
onSubmit={handleSubmit}
|
aria-label="Chế độ định giá"
|
||||||
isLoading={predictMutation.isPending}
|
>
|
||||||
/>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{predictMutation.isError && (
|
{viewMode === 'compare' && avmV2 ? (
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
<ValuationCompare />
|
||||||
Không thể định giá. Vui lòng thử lại sau.
|
) : (
|
||||||
</div>
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
)}
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<ValuationForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={predictMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
{currentResult && (
|
{predictMutation.isError && (
|
||||||
<>
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
{/* Main results with confidence badge + driver charts */}
|
Không thể định giá. Vui lòng thử lại sau.
|
||||||
<ValuationResults result={currentResult} />
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Comparables table (TanStack Table) */}
|
{currentResult && (
|
||||||
{currentResult.comparables.length > 0 && (
|
<>
|
||||||
<ComparablesTable comparables={currentResult.comparables} />
|
<ValuationResults result={currentResult} />
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Market context card */}
|
{avmV2 && currentResult.priceDrivers.length > 0 && (
|
||||||
{currentResult.marketContext && (
|
<ValueDriversChart drivers={currentResult.priceDrivers} />
|
||||||
<MarketContextCard context={currentResult.marketContext} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Valuation history chart */}
|
|
||||||
{currentResult.valuationHistory &&
|
|
||||||
currentResult.valuationHistory.length >= 2 && (
|
|
||||||
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History sidebar (right col) */}
|
{currentResult.comparables.length > 0 && (
|
||||||
<div>
|
<ComparablesTable comparables={currentResult.comparables} />
|
||||||
<ValuationHistory
|
)}
|
||||||
items={historyData?.data ?? []}
|
|
||||||
total={historyData?.total ?? 0}
|
{avmV2 && currentResult.comparables.length > 0 && (
|
||||||
page={historyPage}
|
<ComparablesMap
|
||||||
onPageChange={setHistoryPage}
|
comparables={currentResult.comparables}
|
||||||
onSelect={handleSelectHistory}
|
/>
|
||||||
isLoading={historyLoading}
|
)}
|
||||||
/>
|
|
||||||
|
{currentResult.marketContext && (
|
||||||
|
<MarketContextCard context={currentResult.marketContext} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentResult.valuationHistory &&
|
||||||
|
currentResult.valuationHistory.length >= 2 && (
|
||||||
|
<ValuationHistoryChart data={currentResult.valuationHistory} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ValuationHistory
|
||||||
|
items={historyData?.data ?? []}
|
||||||
|
total={historyData?.total ?? 0}
|
||||||
|
page={historyPage}
|
||||||
|
onPageChange={setHistoryPage}
|
||||||
|
onSelect={handleSelectHistory}
|
||||||
|
isLoading={historyLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
149
apps/web/components/valuation/__tests__/comparables-map.spec.tsx
Normal file
149
apps/web/components/valuation/__tests__/comparables-map.spec.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
230
apps/web/components/valuation/comparables-map.tsx
Normal file
230
apps/web/components/valuation/comparables-map.tsx
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mô 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 có 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/web/components/valuation/value-drivers-chart.tsx
Normal file
147
apps/web/components/valuation/value-drivers-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx
Normal file
99
apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/web/lib/hooks/use-avm-v2-flag.ts
Normal file
55
apps/web/lib/hooks/use-avm-v2-flag.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user