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>
157 lines
4.8 KiB
TypeScript
157 lines
4.8 KiB
TypeScript
'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>
|
|
);
|
|
}
|