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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user