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:
156
apps/web/components/valuation/comparables-table.tsx
Normal file
156
apps/web/components/valuation/comparables-table.tsx
Normal 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ó đặ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>
|
||||
);
|
||||
}
|
||||
176
apps/web/components/valuation/export-pdf-button.tsx
Normal file
176
apps/web/components/valuation/export-pdf-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/web/components/valuation/market-context-card.tsx
Normal file
95
apps/web/components/valuation/market-context-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">Mô 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">Có 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">Có 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">
|
||||
|
||||
110
apps/web/components/valuation/valuation-history-chart.tsx
Normal file
110
apps/web/components/valuation/valuation-history-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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ó đặ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} · {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>
|
||||
|
||||
Reference in New Issue
Block a user