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:
40
apps/web/lib/hooks/use-valuation.ts
Normal file
40
apps/web/lib/hooks/use-valuation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
35
apps/web/lib/validations/valuation.ts
Normal file
35
apps/web/lib/validations/valuation.ts
Normal 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>;
|
||||
85
apps/web/lib/valuation-api.ts
Normal file
85
apps/web/lib/valuation-api.ts
Normal 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}`),
|
||||
};
|
||||
Reference in New Issue
Block a user