feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade

Add batch valuation (POST /analytics/valuation/batch, max 50 properties),
valuation comparison (POST /analytics/valuation/compare, 2-5 properties),
and history endpoint (GET /analytics/valuation/history/:propertyId) with
confidence explanation helper. Frontend: enhanced valuation form with project
autocomplete and deep analysis toggle, results with confidence badges and
price range visualization, comparables table, history chart, market context
card, and PDF export.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:08:05 +07:00
parent 93a390efb9
commit 8da488711b
27 changed files with 1715 additions and 162 deletions

View File

@@ -1,6 +1,10 @@
'use client';
import dynamic from 'next/dynamic';
import { useState } from 'react';
import { ComparablesTable } from '@/components/valuation/comparables-table';
import { ExportPdfButton } from '@/components/valuation/export-pdf-button';
import { MarketContextCard } from '@/components/valuation/market-context-card';
import { ValuationForm } from '@/components/valuation/valuation-form';
import { ValuationHistory } from '@/components/valuation/valuation-history';
import { ValuationResults } from '@/components/valuation/valuation-results';
@@ -11,12 +15,29 @@ import {
} from '@/lib/hooks/use-valuation';
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
// Lazy-load chart component (uses Recharts, no SSR)
const ValuationHistoryChart = dynamic(
() =>
import('@/components/valuation/valuation-history-chart').then(
(m) => m.ValuationHistoryChart,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
),
},
);
export default function ValuationPage() {
const [historyPage, setHistoryPage] = useState(1);
const [selectedId, setSelectedId] = useState<string | null>(null);
const predictMutation = useValuationPredict();
const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage);
const { data: historyData, isLoading: historyLoading } =
useValuationHistory(historyPage);
const { data: selectedResult } = useValuationDetail(selectedId ?? '');
const currentResult: ValuationResult | undefined =
@@ -33,15 +54,24 @@ export default function ValuationPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold sm:text-3xl">Đnh giá AI</h1>
<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
</p>
{/* Page header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold sm:text-3xl">Đnh giá AI</h1>
<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
</p>
</div>
{currentResult && (
<ExportPdfButton
targetSelector="#valuation-results"
filename={`dinh-gia-${currentResult.id}`}
/>
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Form + Results */}
{/* Form + Results (left 2 cols) */}
<div className="space-y-6 lg:col-span-2">
<ValuationForm
onSubmit={handleSubmit}
@@ -54,10 +84,31 @@ export default function ValuationPage() {
</div>
)}
{currentResult && <ValuationResults result={currentResult} />}
{currentResult && (
<>
{/* Main results with confidence badge + driver charts */}
<ValuationResults result={currentResult} />
{/* Comparables table (TanStack Table) */}
{currentResult.comparables.length > 0 && (
<ComparablesTable comparables={currentResult.comparables} />
)}
{/* Market context card */}
{currentResult.marketContext && (
<MarketContextCard context={currentResult.marketContext} />
)}
{/* Valuation history chart */}
{currentResult.valuationHistory &&
currentResult.valuationHistory.length >= 2 && (
<ValuationHistoryChart data={currentResult.valuationHistory} />
)}
</>
)}
</div>
{/* History sidebar */}
{/* History sidebar (right col) */}
<div>
<ValuationHistory
items={historyData?.data ?? []}

View File

@@ -0,0 +1,156 @@
'use client';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from '@tanstack/react-table';
import { ArrowUpDown, MapPin } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ValuationComparable } from '@/lib/valuation-api';
interface ComparablesTableProps {
comparables: ValuationComparable[];
}
const columnHelper = createColumnHelper<ValuationComparable>();
function getSimilarityBadge(similarity: number): {
label: string;
variant: 'success' | 'warning' | 'info';
} {
const pct = Math.round(similarity * 100);
if (pct >= 85) return { label: `${pct}% tương tự`, variant: 'success' };
if (pct >= 70) return { label: `${pct}% tương tự`, variant: 'info' };
return { label: `${pct}% tương tự`, variant: 'warning' };
}
const columns = [
columnHelper.accessor('title', {
header: 'Bất động sản',
cell: (info) => {
const row = info.row.original;
return (
<div className="min-w-0">
<p className="truncate font-medium">{info.getValue()}</p>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
{row.district}
{row.address ? `${row.address}` : ''}
</p>
</div>
);
},
}),
columnHelper.accessor('areaM2', {
header: 'Diện tích',
cell: (info) => <span className="whitespace-nowrap">{info.getValue()} m²</span>,
}),
columnHelper.accessor('priceVND', {
header: 'Giá',
cell: (info) => (
<span className="whitespace-nowrap font-semibold text-primary">
{formatPrice(info.getValue())}
</span>
),
}),
columnHelper.accessor('pricePerM2', {
header: 'Giá/m²',
cell: (info) => (
<span className="whitespace-nowrap text-muted-foreground">
{formatPricePerM2(info.getValue())}
</span>
),
}),
columnHelper.accessor('similarity', {
header: 'Tương đồng',
cell: (info) => {
const badge = getSimilarityBadge(info.getValue());
return <Badge variant={badge.variant}>{badge.label}</Badge>;
},
sortDescFirst: true,
}),
];
export function ComparablesTable({ comparables }: ComparablesTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: 'similarity', desc: true },
]);
const table = useReactTable({
data: comparables,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (comparables.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bất đng sản tương tự</CardTitle>
<CardDescription>
{comparables.length} bất đng sản đc điểm tương tự trong khu vực
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b text-left">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="pb-2 pr-4 font-medium"
>
{header.isPlaceholder ? null : (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</button>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="py-2 pr-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { Download, Loader2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
interface ExportPdfButtonProps {
/** CSS selector for the DOM element to capture */
targetSelector: string;
/** Filename without extension */
filename?: string;
}
export function ExportPdfButton({
targetSelector,
filename = 'dinh-gia-bat-dong-san',
}: ExportPdfButtonProps) {
const [isExporting, setIsExporting] = useState(false);
const handleExport = useCallback(async () => {
setIsExporting(true);
try {
const element = document.querySelector(targetSelector);
if (!element) {
console.error('Export target not found:', targetSelector);
return;
}
// Dynamic imports for client-only PDF libraries
const [html2canvasModule, jsPDFModule] = await Promise.all([
import('html2canvas'),
import('jspdf'),
]);
const html2canvas = html2canvasModule.default;
const { jsPDF } = jsPDFModule;
const canvas = await html2canvas(element as HTMLElement, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff',
});
const imgData = canvas.toDataURL('image/png');
const imgWidth = canvas.width;
const imgHeight = canvas.height;
// A4 dimensions in mm
const pdfWidth = 210;
const pdfMargin = 10;
const contentWidth = pdfWidth - 2 * pdfMargin;
const contentHeight = (imgHeight / imgWidth) * contentWidth;
const pdf = new jsPDF({
orientation: contentHeight > 297 - 2 * pdfMargin ? 'portrait' : 'portrait',
unit: 'mm',
format: 'a4',
});
// Add header
pdf.setFontSize(16);
pdf.setTextColor(34, 139, 34); // Green
pdf.text('GoodGo — Báo cáo định giá', pdfMargin, pdfMargin + 5);
pdf.setFontSize(10);
pdf.setTextColor(128, 128, 128);
pdf.text(
`Ngày: ${new Date().toLocaleDateString('vi-VN')}`,
pdfMargin,
pdfMargin + 12,
);
const headerHeight = 20;
const availableHeight = 297 - 2 * pdfMargin - headerHeight;
if (contentHeight <= availableHeight) {
// Fits on one page
pdf.addImage(
imgData,
'PNG',
pdfMargin,
pdfMargin + headerHeight,
contentWidth,
contentHeight,
);
} else {
// Multi-page: split the image
let yOffset = 0;
let isFirstPage = true;
while (yOffset < contentHeight) {
if (!isFirstPage) {
pdf.addPage();
}
const pageContentHeight = isFirstPage
? availableHeight
: 297 - 2 * pdfMargin;
const pageTopMargin = isFirstPage
? pdfMargin + headerHeight
: pdfMargin;
// Calculate source rectangle in image coordinates
const srcY = (yOffset / contentHeight) * imgHeight;
const srcHeight = (pageContentHeight / contentHeight) * imgHeight;
// Create a temporary canvas for this page slice
const pageCanvas = document.createElement('canvas');
pageCanvas.width = imgWidth;
pageCanvas.height = Math.min(srcHeight, imgHeight - srcY);
const ctx = pageCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(
canvas,
0,
srcY,
imgWidth,
pageCanvas.height,
0,
0,
imgWidth,
pageCanvas.height,
);
}
const pageImgData = pageCanvas.toDataURL('image/png');
const sliceHeight =
(pageCanvas.height / imgWidth) * contentWidth;
pdf.addImage(
pageImgData,
'PNG',
pdfMargin,
pageTopMargin,
contentWidth,
sliceHeight,
);
yOffset += pageContentHeight;
isFirstPage = false;
}
}
// Footer on last page
pdf.setFontSize(8);
pdf.setTextColor(180, 180, 180);
pdf.text(
'Được tạo bởi GoodGo AI Valuation — goodgo.vn',
pdfMargin,
297 - pdfMargin,
);
pdf.save(`${filename}.pdf`);
} catch (error) {
console.error('PDF export failed:', error);
} finally {
setIsExporting(false);
}
}, [targetSelector, filename]);
return (
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={isExporting}
>
{isExporting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{isExporting ? 'Đang xuất...' : 'Xuất PDF'}
</Button>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import {
Building,
CalendarDays,
TrendingDown,
TrendingUp,
Users,
Warehouse,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { MarketContext } from '@/lib/valuation-api';
interface MarketContextCardProps {
context: MarketContext;
}
export function MarketContextCard({ context }: MarketContextCardProps) {
const isGrowthPositive = context.priceGrowthYoY >= 0;
const stats = [
{
label: 'Giá trung bình/m²',
value: formatPricePerM2(context.avgPricePerM2),
icon: Building,
},
{
label: 'Giá trung vị',
value: formatPrice(context.medianPrice),
icon: Warehouse,
},
{
label: 'Tăng trưởng YoY',
value: `${isGrowthPositive ? '+' : ''}${context.priceGrowthYoY.toFixed(1)}%`,
icon: isGrowthPositive ? TrendingUp : TrendingDown,
color: isGrowthPositive ? 'text-green-600' : 'text-red-600',
},
{
label: 'Chỉ số nhu cầu',
value: `${context.demandIndex.toFixed(0)}/100`,
icon: Users,
},
{
label: 'Nguồn cung',
value: `${context.supplyCount.toLocaleString('vi-VN')} BĐS`,
icon: Building,
},
{
label: 'Thời gian bán TB',
value: `${context.avgDaysOnMarket} ngày`,
icon: CalendarDays,
},
];
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bối cảnh thị trường</CardTitle>
<CardDescription>
{context.district}, {context.city} {context.period}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
className="flex items-start gap-3 rounded-lg border p-3"
>
<div className="rounded-md bg-muted p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className={`text-sm font-semibold ${stat.color ?? ''}`}>
{stat.value}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,12 +1,21 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Bot, ImagePlus, Search, X } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { useProjectSearch } from '@/lib/hooks/use-valuation';
import {
valuationFormSchema,
type ValuationFormData,
@@ -30,15 +39,76 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<ValuationFormData>({
resolver: zodResolver(valuationFormSchema),
defaultValues: {
city: 'Ho Chi Minh',
hasLegalPaper: true,
deepAnalysis: false,
},
});
// Project autocomplete state
const [projectQuery, setProjectQuery] = useState('');
const [projectName, setProjectName] = useState('');
const [showProjectDropdown, setShowProjectDropdown] = useState(false);
const { data: projectResults } = useProjectSearch(projectQuery);
const projectInputRef = useRef<HTMLInputElement>(null);
// Image upload state
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setProjectQuery(value);
setProjectName(value);
setShowProjectDropdown(value.length >= 2);
if (!value) {
setValue('projectId', '');
}
}, [setValue]);
const handleSelectProject = useCallback(
(id: string, name: string) => {
setValue('projectId', id);
setProjectName(name);
setProjectQuery('');
setShowProjectDropdown(false);
},
[setValue],
);
const handleImageChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Show local preview
const reader = new FileReader();
reader.onload = (ev) => {
setImagePreview(ev.target?.result as string);
};
reader.readAsDataURL(file);
// In production, upload to server and get URL
// For now we store as data URL for preview purposes
setImageUrl(URL.createObjectURL(file));
},
[],
);
const handleClearImage = useCallback(() => {
setImagePreview(null);
setImageUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
const handleFormSubmit = (data: ValuationFormData) => {
onSubmit({
propertyType: data.propertyType,
@@ -52,6 +122,10 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
roadWidth: toNum(data.roadWidth),
yearBuilt: toNum(data.yearBuilt),
hasLegalPaper: data.hasLegalPaper,
projectId: data.projectId || undefined,
description: data.description || undefined,
deepAnalysis: data.deepAnalysis,
imageUrl: imageUrl || undefined,
});
};
@@ -65,6 +139,56 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Project selector (autocomplete) */}
<div className="space-y-2">
<Label htmlFor="projectSearch">Dự án (tùy chọn)</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="projectSearch"
ref={projectInputRef}
value={projectName}
onChange={handleProjectSearch}
onFocus={() => projectQuery.length >= 2 && setShowProjectDropdown(true)}
onBlur={() => setTimeout(() => setShowProjectDropdown(false), 200)}
placeholder="Tìm kiếm dự án..."
className="pl-9"
/>
{projectName && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => {
setProjectName('');
setProjectQuery('');
setValue('projectId', '');
}}
>
<X className="h-4 w-4" />
</button>
)}
{showProjectDropdown && projectResults?.data && projectResults.data.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-48 overflow-auto rounded-md border bg-popover p-1 shadow-md">
{projectResults.data.map((project) => (
<button
key={project.id}
type="button"
className="flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
onClick={() => handleSelectProject(project.id, project.name)}
>
<div>
<p className="font-medium">{project.name}</p>
<p className="text-xs text-muted-foreground">
{project.district}, {project.city}
</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Row 1: Property type + City */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -194,15 +318,84 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div>
</div>
{/* Legal paper checkbox */}
<div className="flex items-center gap-2">
<input
id="hasLegalPaper"
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register('hasLegalPaper')}
{/* Image upload */}
<div className="space-y-2">
<Label>Hình nh (tùy chọn)</Label>
<div className="flex items-start gap-4">
{imagePreview ? (
<div className="relative h-24 w-24 overflow-hidden rounded-lg border">
<img
src={imagePreview}
alt="Ảnh bất động sản"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={handleClearImage}
className="absolute right-1 top-1 rounded-full bg-background/80 p-0.5 hover:bg-background"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-muted-foreground/25 text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground"
>
<ImagePlus className="h-6 w-6" />
<span className="text-xs">Tải nh</span>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageChange}
/>
<p className="text-xs text-muted-foreground">
Tải nh bất đng sản đ AI phân tích trực quan (JPG, PNG, tối đa 5MB)
</p>
</div>
</div>
{/* Description textarea */}
<div className="space-y-2">
<Label htmlFor="description"> tả thêm (tùy chọn)</Label>
<textarea
id="description"
{...register('description')}
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Mô tả chi tiết về bất động sản: vị trí, hướng, tình trạng, tiện ích lân cận..."
/>
<Label htmlFor="hasLegalPaper"> sổ đ/giấy tờ hợp pháp</Label>
</div>
{/* Toggles row */}
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<input
id="hasLegalPaper"
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register('hasLegalPaper')}
/>
<Label htmlFor="hasLegalPaper"> sổ đ/giấy tờ hợp pháp</Label>
</div>
<div className="flex items-center gap-2">
<input
id="deepAnalysis"
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
{...register('deepAnalysis')}
/>
<Label htmlFor="deepAnalysis" className="flex items-center gap-1.5">
<Bot className="h-4 w-4 text-primary" />
Phân tích chuyên sâu
</Label>
</div>
</div>
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">

View File

@@ -0,0 +1,110 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { formatPrice } from '@/lib/currency';
import type { ValuationHistoryPoint } from '@/lib/valuation-api';
interface ValuationHistoryChartProps {
data: ValuationHistoryPoint[];
}
function formatChartDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('vi-VN', { month: 'short', year: '2-digit' });
}
function formatTooltipDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
export function ValuationHistoryChart({ data }: ValuationHistoryChartProps) {
if (data.length < 2) return null;
const chartData = data.map((point) => ({
date: point.date,
price: point.estimatedPriceVND,
confidence: Math.round(point.confidence * 100),
}));
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Lịch sử đnh giá</CardTitle>
<CardDescription>
Biến đng giá ưc tính theo thời gian
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
<defs>
<linearGradient id="priceGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
className="fill-muted-foreground"
fontSize={12}
/>
<YAxis
tickFormatter={(val: number) => formatPrice(val)}
className="fill-muted-foreground"
fontSize={12}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
labelFormatter={(label) =>
typeof label === 'string' ? formatTooltipDate(label) : label
}
formatter={(value, name) => {
const num = Number(value);
if (name === 'price') return [formatPrice(num) + ' VNĐ', 'Giá ước tính'];
if (name === 'confidence') return [`${num}%`, 'Độ tin cậy'];
return [String(value), name];
}}
/>
<Area
type="monotone"
dataKey="price"
stroke="hsl(var(--primary))"
strokeWidth={2}
fill="url(#priceGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@@ -2,6 +2,7 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatPrice } from '@/lib/currency';
import type { ValuationHistoryItem } from '@/lib/valuation-api';
interface ValuationHistoryProps {
@@ -13,12 +14,6 @@ interface ValuationHistoryProps {
isLoading?: boolean;
}
function formatPrice(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
const PROPERTY_TYPE_LABELS: Record<string, string> = {
APARTMENT: 'Căn hộ',
HOUSE: 'Nhà riêng',

View File

@@ -1,138 +1,219 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { ValuationResult } from '@/lib/valuation-api';
import { ShieldCheck, ShieldAlert, ShieldQuestion, TrendingUp, TrendingDown } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ConfidenceExplanation, ValuationResult } from '@/lib/valuation-api';
interface ValuationResultsProps {
result: ValuationResult;
}
function formatPrice(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
function getConfidenceBadge(confidence: number): {
label: string;
variant: 'success' | 'warning' | 'destructive';
icon: typeof ShieldCheck;
} {
if (confidence >= 0.8) {
return { label: 'Độ tin cậy cao', variant: 'success', icon: ShieldCheck };
}
if (confidence >= 0.5) {
return { label: 'Độ tin cậy trung bình', variant: 'warning', icon: ShieldAlert };
}
return { label: 'Độ tin cậy thấp', variant: 'destructive', icon: ShieldQuestion };
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} đ/m²`;
function ConfidenceDetail({ explanation }: { explanation: ConfidenceExplanation }) {
return (
<div className="mt-4 space-y-3">
<p className="text-sm text-muted-foreground">{explanation.summary}</p>
<div className="space-y-2">
{explanation.factors.map((factor) => (
<div key={factor.factor} className="flex items-start gap-2 text-sm">
<span
className={`mt-0.5 ${
factor.contribution === 'positive' ? 'text-green-600' : 'text-red-600'
}`}
>
{factor.contribution === 'positive' ? (
<TrendingUp className="h-3.5 w-3.5" />
) : (
<TrendingDown className="h-3.5 w-3.5" />
)}
</span>
<div>
<span className="font-medium">{factor.factor}</span>
<span className="text-muted-foreground"> {factor.detail}</span>
</div>
</div>
))}
</div>
</div>
);
}
function PriceRangeBar({
low,
high,
estimated,
}: {
low: number;
high: number;
estimated: number;
}) {
const range = high - low;
const position = range > 0 ? ((estimated - low) / range) * 100 : 50;
return (
<div className="space-y-1">
<div className="relative h-3 rounded-full bg-gradient-to-r from-red-200 via-yellow-200 to-green-200">
<div
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
style={{ left: `${Math.max(5, Math.min(95, position))}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatPrice(low)}</span>
<span className="font-medium text-foreground">{formatPrice(estimated)}</span>
<span>{formatPrice(high)}</span>
</div>
</div>
);
}
export function ValuationResults({ result }: ValuationResultsProps) {
const confidencePct = Math.round(result.confidence * 100);
const badge = getConfidenceBadge(result.confidence);
const BadgeIcon = badge.icon;
// Sort drivers by absolute impact for chart display
const sortedDrivers = [...result.priceDrivers].sort(
(a, b) => Math.abs(b.impact) - Math.abs(a.impact),
);
const maxImpact = Math.max(...sortedDrivers.map((d) => Math.abs(d.impact)), 1);
return (
<div className="space-y-6">
{/* Main estimate */}
<div id="valuation-results" className="space-y-6">
{/* Main estimate card */}
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardDescription>Giá ưc tính bởi AI</CardDescription>
<CardTitle className="text-3xl text-primary">
{formatPrice(result.estimatedPriceVND)} VNĐ
</CardTitle>
<div className="flex items-start justify-between">
<div>
<CardDescription>Giá ưc tính bởi AI</CardDescription>
<CardTitle className="text-3xl text-primary sm:text-4xl">
{formatPrice(result.estimatedPriceVND)} VNĐ
</CardTitle>
</div>
<Badge variant={badge.variant} className="flex items-center gap-1">
<BadgeIcon className="h-3.5 w-3.5" />
{badge.label}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<CardContent className="space-y-4">
{/* Stats grid */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Đ tin cậy</p>
<div className="mt-1 flex items-center gap-2">
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-2.5 flex-1 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all"
className={`h-2.5 rounded-full transition-all ${
confidencePct >= 80
? 'bg-green-500'
: confidencePct >= 50
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${confidencePct}%` }}
/>
</div>
<span className="text-sm font-medium">{confidencePct}%</span>
<span className="text-sm font-semibold">{confidencePct}%</span>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Giá/m²</p>
<p className="mt-1 text-lg font-semibold">{formatPriceM2(result.pricePerM2)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Khoảng giá</p>
<p className="mt-1 text-lg font-semibold">
{formatPrice(result.priceRangeLow)} {formatPrice(result.priceRangeHigh)}
{formatPricePerM2(result.pricePerM2)}
</p>
</div>
</div>
{/* Price range bar */}
<div>
<p className="mb-2 text-sm text-muted-foreground">Khoảng giá</p>
<PriceRangeBar
low={result.priceRangeLow}
high={result.priceRangeHigh}
estimated={result.estimatedPriceVND}
/>
</div>
{/* Confidence explanation (deep analysis) */}
{result.confidenceExplanation && (
<ConfidenceDetail explanation={result.confidenceExplanation} />
)}
</CardContent>
</Card>
{/* Price drivers */}
{result.priceDrivers.length > 0 && (
{/* Value drivers — horizontal bar chart */}
{sortedDrivers.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Yếu tố nh hưởng giá</CardTitle>
<CardDescription>Các yếu tố chính tác đng đến giá trị bất đng sản</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{result.priceDrivers.map((driver) => (
<div key={driver.feature} className="flex items-center gap-3">
<span
className={`text-sm font-medium ${
driver.direction === 'positive' ? 'text-green-600' : 'text-red-600'
}`}
>
{driver.direction === 'positive' ? '+' : '-'}
{Math.abs(driver.impact).toFixed(1)}%
</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm">{driver.feature}</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-muted">
<div
className={`h-1.5 rounded-full ${
driver.direction === 'positive' ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ width: `${Math.min(Math.abs(driver.impact), 100)}%` }}
/>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Comparables */}
{result.comparables.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bất đng sản tương tự</CardTitle>
<CardDescription>
{result.comparables.length} bất đng sản đc điểm tương tự trong khu vực
Các yếu tố chính tác đng đến giá trị bất đng sản
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{result.comparables.map((comp) => (
<div
key={comp.id}
className="flex items-center gap-4 rounded-lg border p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{comp.title}</p>
<p className="text-sm text-muted-foreground">
{comp.district} &middot; {comp.areaM2} m²
</p>
<div className="space-y-4">
{sortedDrivers.map((driver) => {
const barWidth = (Math.abs(driver.impact) / maxImpact) * 100;
const isPositive = driver.direction === 'positive';
return (
<div key={driver.feature} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{driver.feature}</span>
<span
className={`font-semibold ${
isPositive ? 'text-green-600' : 'text-red-600'
}`}
>
{isPositive ? '+' : '-'}
{Math.abs(driver.impact).toFixed(1)}%
</span>
</div>
<div className="relative h-6 rounded bg-muted">
<div
className={`absolute inset-y-0 left-0 flex items-center rounded transition-all ${
isPositive
? 'bg-green-500/20 text-green-700'
: 'bg-red-500/20 text-red-700'
}`}
style={{ width: `${Math.max(barWidth, 8)}%` }}
>
<div
className={`h-full rounded ${
isPositive ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ width: '100%', opacity: 0.7 }}
/>
</div>
</div>
{driver.explanation && (
<p className="text-xs text-muted-foreground">{driver.explanation}</p>
)}
</div>
<div className="text-right">
<p className="font-semibold text-primary">{formatPrice(Number(comp.priceVND))}</p>
<p className="text-sm text-muted-foreground">
{formatPriceM2(comp.pricePerM2)}
</p>
</div>
<div className="hidden sm:block">
<span className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
{Math.round(comp.similarity * 100)}% tương tự
</span>
</div>
</div>
))}
);
})}
</div>
</CardContent>
</Card>

View File

@@ -1,10 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { valuationApi, type ValuationRequest } from '@/lib/valuation-api';
import {
valuationApi,
type ValuationRequest,
type BatchValuationRequest,
} from '@/lib/valuation-api';
export const valuationKeys = {
all: ['valuation'] as const,
history: (page: number) => ['valuation', 'history', page] as const,
detail: (id: string) => ['valuation', 'detail', id] as const,
propertyHistory: (propertyId: string) =>
['valuation', 'property-history', propertyId] as const,
compare: (ids: string[]) => ['valuation', 'compare', ...ids] as const,
projectSearch: (query: string) =>
['valuation', 'project-search', query] as const,
};
export function useValuationPredict() {
@@ -24,6 +33,25 @@ export function useValuationPredictForListing() {
});
}
export function useValuationBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: BatchValuationRequest) => valuationApi.batchPredict(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: valuationKeys.all });
},
});
}
export function useValuationCompare(propertyIds: string[]) {
return useQuery({
queryKey: valuationKeys.compare(propertyIds),
queryFn: () => valuationApi.compare({ propertyIds }),
enabled: propertyIds.length >= 2,
});
}
export function useValuationHistory(page = 1) {
return useQuery({
queryKey: valuationKeys.history(page),
@@ -31,6 +59,14 @@ export function useValuationHistory(page = 1) {
});
}
export function useValuationPropertyHistory(propertyId: string) {
return useQuery({
queryKey: valuationKeys.propertyHistory(propertyId),
queryFn: () => valuationApi.getPropertyHistory(propertyId),
enabled: !!propertyId,
});
}
export function useValuationDetail(id: string) {
return useQuery({
queryKey: valuationKeys.detail(id),
@@ -38,3 +74,12 @@ export function useValuationDetail(id: string) {
enabled: !!id,
});
}
export function useProjectSearch(query: string) {
return useQuery({
queryKey: valuationKeys.projectSearch(query),
queryFn: () => valuationApi.searchProjects(query),
enabled: query.length >= 2,
staleTime: 30_000,
});
}

View File

@@ -1,28 +1,28 @@
import { z } from 'zod';
export const VALUATION_PROPERTY_TYPES = [
{ value: 'APARTMENT', label: 'Can ho' },
{ value: 'HOUSE', label: 'Nha rieng' },
{ value: 'VILLA', label: 'Biet thu' },
{ value: 'LAND', label: 'Dat nen' },
{ value: 'OFFICE', label: 'Van phong' },
{ value: 'APARTMENT', label: 'Căn h' },
{ value: 'HOUSE', label: 'Nhà riêng' },
{ value: 'VILLA', label: 'Bit th' },
{ value: 'LAND', label: 'Đất nn' },
{ value: 'OFFICE', label: 'Văn phòng' },
{ value: 'SHOPHOUSE', label: 'Shophouse' },
] as const;
export const CITIES = [
{ value: 'Ho Chi Minh', label: 'TP. Ho Chi Minh' },
{ value: 'Ha Noi', label: 'Ha Noi' },
{ value: 'Da Nang', label: 'Da Nang' },
{ value: 'Ho Chi Minh', label: 'TP. H Chí Minh' },
{ value: 'Ha Noi', label: 'Hà Ni' },
{ value: 'Da Nang', label: 'Đà Nng' },
] as const;
export const valuationFormSchema = z.object({
propertyType: z.string().min(1, 'Vui long chon loai bat dong san'),
area: z.string().min(1, 'Vui long nhap dien tich').refine(
propertyType: z.string().min(1, 'Vui lòng chn loi bt động sn'),
area: z.string().min(1, 'Vui lòng nhp din tích').refine(
(val) => !isNaN(Number(val)) && Number(val) > 0,
'Dien tich phai lon hon 0',
'Din tích phi ln hơn 0',
),
district: z.string().min(1, 'Vui long nhap quan/huyen'),
city: z.string().min(1, 'Vui long chon tinh/thanh pho'),
district: z.string().min(1, 'Vui lòng nhp qun/huyn'),
city: z.string().min(1, 'Vui lòng chn tnh/thành ph'),
bedrooms: z.string().optional(),
bathrooms: z.string().optional(),
floors: z.string().optional(),
@@ -30,6 +30,10 @@ export const valuationFormSchema = z.object({
roadWidth: z.string().optional(),
yearBuilt: z.string().optional(),
hasLegalPaper: z.boolean().optional(),
/** New fields for enhanced form */
projectId: z.string().optional(),
description: z.string().optional(),
deepAnalysis: z.boolean().optional(),
});
export type ValuationFormData = z.infer<typeof valuationFormSchema>;

View File

@@ -16,6 +16,14 @@ export interface ValuationRequest {
hasLegalPaper?: boolean;
latitude?: number;
longitude?: number;
/** Optional project ID for project-based valuation */
projectId?: string;
/** Image file for visual analysis */
imageUrl?: string;
/** Description text for AI context */
description?: string;
/** Request deep analysis (confidence explanation, more drivers) */
deepAnalysis?: boolean;
}
export interface ValuationComparable {
@@ -27,6 +35,11 @@ export interface ValuationComparable {
areaM2: number;
pricePerM2: number;
similarity: number;
propertyType?: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
yearBuilt?: number;
latitude?: number;
longitude?: number;
}
@@ -35,6 +48,37 @@ export interface PriceDriver {
feature: string;
impact: number;
direction: 'positive' | 'negative';
/** Human-readable explanation of this driver's impact */
explanation?: string;
}
export interface MarketContext {
avgPricePerM2: number;
medianPrice: number;
priceGrowthYoY: number;
demandIndex: number;
supplyCount: number;
avgDaysOnMarket: number;
district: string;
city: string;
period: string;
}
export interface ValuationHistoryPoint {
date: string;
estimatedPriceVND: number;
confidence: number;
}
export interface ConfidenceExplanation {
level: 'high' | 'medium' | 'low';
score: number;
factors: Array<{
factor: string;
contribution: 'positive' | 'negative';
detail: string;
}>;
summary: string;
}
export interface ValuationResult {
@@ -48,6 +92,10 @@ export interface ValuationResult {
priceDrivers: PriceDriver[];
modelVersion: string;
createdAt: string;
/** Enhanced fields from deep analysis */
confidenceExplanation?: ConfidenceExplanation;
marketContext?: MarketContext;
valuationHistory?: ValuationHistoryPoint[];
}
export interface ValuationHistoryItem {
@@ -68,27 +116,105 @@ export interface ValuationHistoryResponse {
limit: number;
}
export interface BatchValuationRequest {
properties: ValuationRequest[];
}
export interface BatchValuationResponse {
results: ValuationResult[];
totalProcessed: number;
errors: Array<{ index: number; message: string }>;
}
export interface ValuationCompareRequest {
propertyIds: string[];
}
export interface ValuationCompareResponse {
properties: Array<{
id: string;
valuation: ValuationResult;
property: {
title: string;
district: string;
city: string;
area: number;
propertyType: string;
};
}>;
}
export interface ProjectSuggestion {
id: string;
name: string;
district: string;
city: string;
type: string;
}
// ─── API ────────────────────────────────────────────────
export const valuationApi = {
/** Request AVM estimate via GET /analytics/valuation */
/** Request AVM estimate via POST /analytics/valuation */
predict: (data: ValuationRequest) => {
const params = new URLSearchParams();
if (data.latitude) params.set('latitude', String(data.latitude));
if (data.longitude) params.set('longitude', String(data.longitude));
if (data.area) params.set('areaM2', String(data.area));
if (data.propertyType) params.set('propertyType', data.propertyType);
const qs = params.toString();
return apiClient.get<ValuationResult>(`/analytics/valuation${qs ? `?${qs}` : ''}`);
// Build request body with all fields
const body: Record<string, unknown> = {
propertyType: data.propertyType,
areaM2: data.area,
district: data.district,
city: data.city,
};
if (data.bedrooms != null) body['bedrooms'] = data.bedrooms;
if (data.bathrooms != null) body['bathrooms'] = data.bathrooms;
if (data.floors != null) body['floors'] = data.floors;
if (data.frontage != null) body['frontage'] = data.frontage;
if (data.roadWidth != null) body['roadWidth'] = data.roadWidth;
if (data.yearBuilt != null) body['yearBuilt'] = data.yearBuilt;
if (data.hasLegalPaper != null) body['hasLegalPaper'] = data.hasLegalPaper;
if (data.latitude) body['latitude'] = data.latitude;
if (data.longitude) body['longitude'] = data.longitude;
if (data.projectId) body['projectId'] = data.projectId;
if (data.imageUrl) body['imageUrl'] = data.imageUrl;
if (data.description) body['description'] = data.description;
if (data.deepAnalysis) body['deepAnalysis'] = data.deepAnalysis;
return apiClient.post<ValuationResult>('/analytics/valuation', body);
},
/** History is not available server-side — return empty result */
getHistory: (_page = 1, _limit = 10): Promise<ValuationHistoryResponse> =>
Promise.resolve({ data: [], total: 0, page: _page, limit: _limit }),
/** Batch valuation: POST /analytics/valuation/batch (max 50) */
batchPredict: (data: BatchValuationRequest) =>
apiClient.post<BatchValuationResponse>('/analytics/valuation/batch', data),
/** Get valuation history for a property: GET /analytics/valuation/history/:propertyId */
getPropertyHistory: (propertyId: string) =>
apiClient.get<{ data: ValuationHistoryPoint[] }>(
`/analytics/valuation/history/${propertyId}`,
),
/** Compare valuations: POST /analytics/valuation/compare */
compare: (data: ValuationCompareRequest) =>
apiClient.post<ValuationCompareResponse>('/analytics/valuation/compare', data),
/** User valuation history (paginated) */
getHistory: (page = 1, limit = 10) =>
apiClient.get<ValuationHistoryResponse>(
`/analytics/valuation/user-history?page=${page}&limit=${limit}`,
),
/** Get single valuation by ID */
getById: (id: string) =>
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${id}`),
apiClient.get<ValuationResult>(`/analytics/valuation/${id}`),
/** Predict for existing listing */
predictForListing: (listingId: string) =>
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${listingId}`),
apiClient.post<ValuationResult>('/analytics/valuation', {
propertyId: listingId,
}),
/** Search projects for autocomplete */
searchProjects: (query: string) =>
apiClient.get<{ data: ProjectSuggestion[] }>(
`/projects/search?q=${encodeURIComponent(query)}&limit=10`,
),
};