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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user