feat(web): add Property Valuation UI with AVM integration

Build the valuation page at /dashboard/valuation with form input,
AI-powered price estimation results, comparable properties display,
and valuation history. Add "Dinh gia AI" button to listing detail
sidebar for quick per-listing estimates.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 00:17:12 +07:00
parent 6f3e6998ac
commit 3c6ed4c82a
10 changed files with 792 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { valuationApi, type ValuationRequest } 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,
};
export function useValuationPredict() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ValuationRequest) => valuationApi.predict(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: valuationKeys.all });
},
});
}
export function useValuationPredictForListing() {
return useMutation({
mutationFn: (listingId: string) => valuationApi.predictForListing(listingId),
});
}
export function useValuationHistory(page = 1) {
return useQuery({
queryKey: valuationKeys.history(page),
queryFn: () => valuationApi.getHistory(page),
});
}
export function useValuationDetail(id: string) {
return useQuery({
queryKey: valuationKeys.detail(id),
queryFn: () => valuationApi.getById(id),
enabled: !!id,
});
}

View File

@@ -0,0 +1,35 @@
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: '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' },
] 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(
(val) => !isNaN(Number(val)) && Number(val) > 0,
'Dien tich phai lon hon 0',
),
district: z.string().min(1, 'Vui long nhap quan/huyen'),
city: z.string().min(1, 'Vui long chon tinh/thanh pho'),
bedrooms: z.string().optional(),
bathrooms: z.string().optional(),
floors: z.string().optional(),
frontage: z.string().optional(),
roadWidth: z.string().optional(),
yearBuilt: z.string().optional(),
hasLegalPaper: z.boolean().optional(),
});
export type ValuationFormData = z.infer<typeof valuationFormSchema>;

View File

@@ -0,0 +1,85 @@
import { apiClient } from './api-client';
// ─── Types ──────────────────────────────────────────────
export interface ValuationRequest {
propertyType: string;
area: number;
district: string;
city: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
frontage?: number;
roadWidth?: number;
yearBuilt?: number;
hasLegalPaper?: boolean;
}
export interface ValuationComparable {
id: string;
title: string;
address: string;
district: string;
priceVND: string;
areaM2: number;
pricePerM2: number;
similarity: number;
latitude?: number;
longitude?: number;
}
export interface PriceDriver {
feature: string;
impact: number;
direction: 'positive' | 'negative';
}
export interface ValuationResult {
id: string;
estimatedPriceVND: number;
confidence: number;
pricePerM2: number;
priceRangeLow: number;
priceRangeHigh: number;
comparables: ValuationComparable[];
priceDrivers: PriceDriver[];
modelVersion: string;
createdAt: string;
}
export interface ValuationHistoryItem {
id: string;
propertyType: string;
district: string;
city: string;
area: number;
estimatedPriceVND: number;
confidence: number;
createdAt: string;
}
export interface ValuationHistoryResponse {
data: ValuationHistoryItem[];
total: number;
page: number;
limit: number;
}
// ─── API ────────────────────────────────────────────────
export const valuationApi = {
predict: (data: ValuationRequest) =>
apiClient.post<ValuationResult>('/valuation/predict', data),
getHistory: (page = 1, limit = 10) =>
apiClient.get<ValuationHistoryResponse>(
`/valuation/history?page=${page}&limit=${limit}`,
),
getById: (id: string) =>
apiClient.get<ValuationResult>(`/valuation/${id}`),
predictForListing: (listingId: string) =>
apiClient.post<ValuationResult>(`/valuation/predict-listing/${listingId}`),
};