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,10 +1,19 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { valuationApi, type ValuationRequest } from '@/lib/valuation-api';
|
||||
import {
|
||||
valuationApi,
|
||||
type ValuationRequest,
|
||||
type BatchValuationRequest,
|
||||
} 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,
|
||||
propertyHistory: (propertyId: string) =>
|
||||
['valuation', 'property-history', propertyId] as const,
|
||||
compare: (ids: string[]) => ['valuation', 'compare', ...ids] as const,
|
||||
projectSearch: (query: string) =>
|
||||
['valuation', 'project-search', query] as const,
|
||||
};
|
||||
|
||||
export function useValuationPredict() {
|
||||
@@ -24,6 +33,25 @@ export function useValuationPredictForListing() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useValuationBatch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: BatchValuationRequest) => valuationApi.batchPredict(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: valuationKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useValuationCompare(propertyIds: string[]) {
|
||||
return useQuery({
|
||||
queryKey: valuationKeys.compare(propertyIds),
|
||||
queryFn: () => valuationApi.compare({ propertyIds }),
|
||||
enabled: propertyIds.length >= 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function useValuationHistory(page = 1) {
|
||||
return useQuery({
|
||||
queryKey: valuationKeys.history(page),
|
||||
@@ -31,6 +59,14 @@ export function useValuationHistory(page = 1) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useValuationPropertyHistory(propertyId: string) {
|
||||
return useQuery({
|
||||
queryKey: valuationKeys.propertyHistory(propertyId),
|
||||
queryFn: () => valuationApi.getPropertyHistory(propertyId),
|
||||
enabled: !!propertyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useValuationDetail(id: string) {
|
||||
return useQuery({
|
||||
queryKey: valuationKeys.detail(id),
|
||||
@@ -38,3 +74,12 @@ export function useValuationDetail(id: string) {
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectSearch(query: string) {
|
||||
return useQuery({
|
||||
queryKey: valuationKeys.projectSearch(query),
|
||||
queryFn: () => valuationApi.searchProjects(query),
|
||||
enabled: query.length >= 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
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: 'APARTMENT', label: 'Căn hộ' },
|
||||
{ value: 'HOUSE', label: 'Nhà riêng' },
|
||||
{ value: 'VILLA', label: 'Biệt thự' },
|
||||
{ value: 'LAND', label: 'Đất nền' },
|
||||
{ value: 'OFFICE', label: 'Văn phòng' },
|
||||
{ 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' },
|
||||
{ value: 'Ho Chi Minh', label: 'TP. Hồ Chí Minh' },
|
||||
{ value: 'Ha Noi', label: 'Hà Nội' },
|
||||
{ value: 'Da Nang', label: 'Đà Nẵng' },
|
||||
] 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(
|
||||
propertyType: z.string().min(1, 'Vui lòng chọn loại bất động sản'),
|
||||
area: z.string().min(1, 'Vui lòng nhập diện tích').refine(
|
||||
(val) => !isNaN(Number(val)) && Number(val) > 0,
|
||||
'Dien tich phai lon hon 0',
|
||||
'Diện tích phải lớn hơn 0',
|
||||
),
|
||||
district: z.string().min(1, 'Vui long nhap quan/huyen'),
|
||||
city: z.string().min(1, 'Vui long chon tinh/thanh pho'),
|
||||
district: z.string().min(1, 'Vui lòng nhập quận/huyện'),
|
||||
city: z.string().min(1, 'Vui lòng chọn tỉnh/thành phố'),
|
||||
bedrooms: z.string().optional(),
|
||||
bathrooms: z.string().optional(),
|
||||
floors: z.string().optional(),
|
||||
@@ -30,6 +30,10 @@ export const valuationFormSchema = z.object({
|
||||
roadWidth: z.string().optional(),
|
||||
yearBuilt: z.string().optional(),
|
||||
hasLegalPaper: z.boolean().optional(),
|
||||
/** New fields for enhanced form */
|
||||
projectId: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
deepAnalysis: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ValuationFormData = z.infer<typeof valuationFormSchema>;
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface ValuationRequest {
|
||||
hasLegalPaper?: boolean;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
/** Optional project ID for project-based valuation */
|
||||
projectId?: string;
|
||||
/** Image file for visual analysis */
|
||||
imageUrl?: string;
|
||||
/** Description text for AI context */
|
||||
description?: string;
|
||||
/** Request deep analysis (confidence explanation, more drivers) */
|
||||
deepAnalysis?: boolean;
|
||||
}
|
||||
|
||||
export interface ValuationComparable {
|
||||
@@ -27,6 +35,11 @@ export interface ValuationComparable {
|
||||
areaM2: number;
|
||||
pricePerM2: number;
|
||||
similarity: number;
|
||||
propertyType?: string;
|
||||
bedrooms?: number;
|
||||
bathrooms?: number;
|
||||
floors?: number;
|
||||
yearBuilt?: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
@@ -35,6 +48,37 @@ export interface PriceDriver {
|
||||
feature: string;
|
||||
impact: number;
|
||||
direction: 'positive' | 'negative';
|
||||
/** Human-readable explanation of this driver's impact */
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
export interface MarketContext {
|
||||
avgPricePerM2: number;
|
||||
medianPrice: number;
|
||||
priceGrowthYoY: number;
|
||||
demandIndex: number;
|
||||
supplyCount: number;
|
||||
avgDaysOnMarket: number;
|
||||
district: string;
|
||||
city: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface ValuationHistoryPoint {
|
||||
date: string;
|
||||
estimatedPriceVND: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface ConfidenceExplanation {
|
||||
level: 'high' | 'medium' | 'low';
|
||||
score: number;
|
||||
factors: Array<{
|
||||
factor: string;
|
||||
contribution: 'positive' | 'negative';
|
||||
detail: string;
|
||||
}>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ValuationResult {
|
||||
@@ -48,6 +92,10 @@ export interface ValuationResult {
|
||||
priceDrivers: PriceDriver[];
|
||||
modelVersion: string;
|
||||
createdAt: string;
|
||||
/** Enhanced fields from deep analysis */
|
||||
confidenceExplanation?: ConfidenceExplanation;
|
||||
marketContext?: MarketContext;
|
||||
valuationHistory?: ValuationHistoryPoint[];
|
||||
}
|
||||
|
||||
export interface ValuationHistoryItem {
|
||||
@@ -68,27 +116,105 @@ export interface ValuationHistoryResponse {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface BatchValuationRequest {
|
||||
properties: ValuationRequest[];
|
||||
}
|
||||
|
||||
export interface BatchValuationResponse {
|
||||
results: ValuationResult[];
|
||||
totalProcessed: number;
|
||||
errors: Array<{ index: number; message: string }>;
|
||||
}
|
||||
|
||||
export interface ValuationCompareRequest {
|
||||
propertyIds: string[];
|
||||
}
|
||||
|
||||
export interface ValuationCompareResponse {
|
||||
properties: Array<{
|
||||
id: string;
|
||||
valuation: ValuationResult;
|
||||
property: {
|
||||
title: string;
|
||||
district: string;
|
||||
city: string;
|
||||
area: number;
|
||||
propertyType: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProjectSuggestion {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
city: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────────
|
||||
|
||||
export const valuationApi = {
|
||||
/** Request AVM estimate via GET /analytics/valuation */
|
||||
/** Request AVM estimate via POST /analytics/valuation */
|
||||
predict: (data: ValuationRequest) => {
|
||||
const params = new URLSearchParams();
|
||||
if (data.latitude) params.set('latitude', String(data.latitude));
|
||||
if (data.longitude) params.set('longitude', String(data.longitude));
|
||||
if (data.area) params.set('areaM2', String(data.area));
|
||||
if (data.propertyType) params.set('propertyType', data.propertyType);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<ValuationResult>(`/analytics/valuation${qs ? `?${qs}` : ''}`);
|
||||
// Build request body with all fields
|
||||
const body: Record<string, unknown> = {
|
||||
propertyType: data.propertyType,
|
||||
areaM2: data.area,
|
||||
district: data.district,
|
||||
city: data.city,
|
||||
};
|
||||
|
||||
if (data.bedrooms != null) body['bedrooms'] = data.bedrooms;
|
||||
if (data.bathrooms != null) body['bathrooms'] = data.bathrooms;
|
||||
if (data.floors != null) body['floors'] = data.floors;
|
||||
if (data.frontage != null) body['frontage'] = data.frontage;
|
||||
if (data.roadWidth != null) body['roadWidth'] = data.roadWidth;
|
||||
if (data.yearBuilt != null) body['yearBuilt'] = data.yearBuilt;
|
||||
if (data.hasLegalPaper != null) body['hasLegalPaper'] = data.hasLegalPaper;
|
||||
if (data.latitude) body['latitude'] = data.latitude;
|
||||
if (data.longitude) body['longitude'] = data.longitude;
|
||||
if (data.projectId) body['projectId'] = data.projectId;
|
||||
if (data.imageUrl) body['imageUrl'] = data.imageUrl;
|
||||
if (data.description) body['description'] = data.description;
|
||||
if (data.deepAnalysis) body['deepAnalysis'] = data.deepAnalysis;
|
||||
|
||||
return apiClient.post<ValuationResult>('/analytics/valuation', body);
|
||||
},
|
||||
|
||||
/** History is not available server-side — return empty result */
|
||||
getHistory: (_page = 1, _limit = 10): Promise<ValuationHistoryResponse> =>
|
||||
Promise.resolve({ data: [], total: 0, page: _page, limit: _limit }),
|
||||
/** Batch valuation: POST /analytics/valuation/batch (max 50) */
|
||||
batchPredict: (data: BatchValuationRequest) =>
|
||||
apiClient.post<BatchValuationResponse>('/analytics/valuation/batch', data),
|
||||
|
||||
/** Get valuation history for a property: GET /analytics/valuation/history/:propertyId */
|
||||
getPropertyHistory: (propertyId: string) =>
|
||||
apiClient.get<{ data: ValuationHistoryPoint[] }>(
|
||||
`/analytics/valuation/history/${propertyId}`,
|
||||
),
|
||||
|
||||
/** Compare valuations: POST /analytics/valuation/compare */
|
||||
compare: (data: ValuationCompareRequest) =>
|
||||
apiClient.post<ValuationCompareResponse>('/analytics/valuation/compare', data),
|
||||
|
||||
/** User valuation history (paginated) */
|
||||
getHistory: (page = 1, limit = 10) =>
|
||||
apiClient.get<ValuationHistoryResponse>(
|
||||
`/analytics/valuation/user-history?page=${page}&limit=${limit}`,
|
||||
),
|
||||
|
||||
/** Get single valuation by ID */
|
||||
getById: (id: string) =>
|
||||
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${id}`),
|
||||
apiClient.get<ValuationResult>(`/analytics/valuation/${id}`),
|
||||
|
||||
/** Predict for existing listing */
|
||||
predictForListing: (listingId: string) =>
|
||||
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${listingId}`),
|
||||
apiClient.post<ValuationResult>('/analytics/valuation', {
|
||||
propertyId: listingId,
|
||||
}),
|
||||
|
||||
/** Search projects for autocomplete */
|
||||
searchProjects: (query: string) =>
|
||||
apiClient.get<{ data: ProjectSuggestion[] }>(
|
||||
`/projects/search?q=${encodeURIComponent(query)}&limit=10`,
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user