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:
74
apps/web/app/(dashboard)/dashboard/valuation/page.tsx
Normal file
74
apps/web/app/(dashboard)/dashboard/valuation/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ValuationForm } from '@/components/valuation/valuation-form';
|
||||||
|
import { ValuationHistory } from '@/components/valuation/valuation-history';
|
||||||
|
import { ValuationResults } from '@/components/valuation/valuation-results';
|
||||||
|
import {
|
||||||
|
useValuationPredict,
|
||||||
|
useValuationHistory,
|
||||||
|
useValuationDetail,
|
||||||
|
} from '@/lib/hooks/use-valuation';
|
||||||
|
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
export default function ValuationPage() {
|
||||||
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const predictMutation = useValuationPredict();
|
||||||
|
const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage);
|
||||||
|
const { data: selectedResult } = useValuationDetail(selectedId ?? '');
|
||||||
|
|
||||||
|
const currentResult: ValuationResult | undefined =
|
||||||
|
predictMutation.data ?? selectedResult;
|
||||||
|
|
||||||
|
const handleSubmit = (data: ValuationRequest) => {
|
||||||
|
setSelectedId(null);
|
||||||
|
predictMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectHistory = (id: string) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Dinh gia AI</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Form + Results */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<ValuationForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={predictMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{predictMutation.isError && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
Khong the dinh gia. Vui long thu lai sau.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentResult && <ValuationResults result={currentResult} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History sidebar */}
|
||||||
|
<div>
|
||||||
|
<ValuationHistory
|
||||||
|
items={historyData?.data ?? []}
|
||||||
|
total={historyData?.total ?? 0}
|
||||||
|
page={historyPage}
|
||||||
|
onPageChange={setHistoryPage}
|
||||||
|
onSelect={handleSelectHistory}
|
||||||
|
isLoading={historyLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ const navItems = [
|
|||||||
{ href: '/listings', label: 'Tin đăng', icon: '📋' },
|
{ href: '/listings', label: 'Tin đăng', icon: '📋' },
|
||||||
{ href: '/listings/new', label: 'Đăng tin', icon: '➕' },
|
{ href: '/listings/new', label: 'Đăng tin', icon: '➕' },
|
||||||
{ href: '/analytics', label: 'Phân tích', icon: '📊' },
|
{ href: '/analytics', label: 'Phân tích', icon: '📊' },
|
||||||
|
{ href: '/dashboard/valuation', label: 'Định giá AI', icon: '🤖' },
|
||||||
{ href: '/dashboard/profile', label: 'Hồ sơ', icon: '👤' },
|
{ href: '/dashboard/profile', label: 'Hồ sơ', icon: '👤' },
|
||||||
{ href: '/dashboard/subscription', label: 'Gói dịch vụ', icon: '💎' },
|
{ href: '/dashboard/subscription', label: 'Gói dịch vụ', icon: '💎' },
|
||||||
{ href: '/dashboard/payments', label: 'Thanh toán', icon: '💳' },
|
{ href: '/dashboard/payments', label: 'Thanh toán', icon: '💳' },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ImageGallery } from '@/components/listings/image-gallery';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
|
||||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||||
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||||
|
|
||||||
@@ -264,6 +265,9 @@ export default function PublicListingDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* AI Estimate */}
|
||||||
|
<AiEstimateButton listingId={listing.id} />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|||||||
80
apps/web/components/valuation/ai-estimate-button.tsx
Normal file
80
apps/web/components/valuation/ai-estimate-button.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useValuationPredictForListing } from '@/lib/hooks/use-valuation';
|
||||||
|
|
||||||
|
interface AiEstimateButtonProps {
|
||||||
|
listingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
|
||||||
|
return num.toLocaleString('vi-VN');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiEstimateButton({ listingId }: AiEstimateButtonProps) {
|
||||||
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
const mutation = useValuationPredictForListing();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
mutation.mutate(listingId, {
|
||||||
|
onSuccess: () => setShowResult(true),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showResult && mutation.data) {
|
||||||
|
const result = mutation.data;
|
||||||
|
const confidencePct = Math.round(result.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Gia uoc tinh AI</CardDescription>
|
||||||
|
<CardTitle className="text-xl text-primary">
|
||||||
|
{formatPrice(result.estimatedPriceVND)} VND
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Do tin cay</span>
|
||||||
|
<span className="font-medium">{confidencePct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-primary"
|
||||||
|
style={{ width: `${confidencePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Khoang gia</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatPrice(result.priceRangeLow)} - {formatPrice(result.priceRangeHigh)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{mutation.isPending ? 'Dang dinh gia...' : 'Dinh gia AI'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
apps/web/components/valuation/valuation-form.tsx
Normal file
215
apps/web/components/valuation/valuation-form.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
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 {
|
||||||
|
valuationFormSchema,
|
||||||
|
type ValuationFormData,
|
||||||
|
VALUATION_PROPERTY_TYPES,
|
||||||
|
CITIES,
|
||||||
|
} from '@/lib/validations/valuation';
|
||||||
|
import type { ValuationRequest } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ValuationFormProps {
|
||||||
|
onSubmit: (data: ValuationRequest) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(val: string | undefined): number | undefined {
|
||||||
|
if (!val || val === '') return undefined;
|
||||||
|
const n = Number(val);
|
||||||
|
return isNaN(n) ? undefined : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ValuationFormData>({
|
||||||
|
resolver: zodResolver(valuationFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
city: 'Ho Chi Minh',
|
||||||
|
hasLegalPaper: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = (data: ValuationFormData) => {
|
||||||
|
onSubmit({
|
||||||
|
propertyType: data.propertyType,
|
||||||
|
area: Number(data.area),
|
||||||
|
district: data.district,
|
||||||
|
city: data.city,
|
||||||
|
bedrooms: toNum(data.bedrooms),
|
||||||
|
bathrooms: toNum(data.bathrooms),
|
||||||
|
floors: toNum(data.floors),
|
||||||
|
frontage: toNum(data.frontage),
|
||||||
|
roadWidth: toNum(data.roadWidth),
|
||||||
|
yearBuilt: toNum(data.yearBuilt),
|
||||||
|
hasLegalPaper: data.hasLegalPaper,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dinh gia bat dong san</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Nhap thong tin bat dong san de nhan uoc tinh gia tu AI
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
|
{/* Row 1: Property type + City */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="propertyType">Loai bat dong san *</Label>
|
||||||
|
<Select id="propertyType" {...register('propertyType')}>
|
||||||
|
<option value="">-- Chon loai --</option>
|
||||||
|
{VALUATION_PROPERTY_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{errors.propertyType && (
|
||||||
|
<p className="text-sm text-destructive">{errors.propertyType.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="city">Tinh/Thanh pho *</Label>
|
||||||
|
<Select id="city" {...register('city')}>
|
||||||
|
{CITIES.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{errors.city && (
|
||||||
|
<p className="text-sm text-destructive">{errors.city.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: District + Area */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="district">Quan/Huyen *</Label>
|
||||||
|
<Input
|
||||||
|
id="district"
|
||||||
|
placeholder="VD: Quan 1, Binh Thanh..."
|
||||||
|
{...register('district')}
|
||||||
|
/>
|
||||||
|
{errors.district && (
|
||||||
|
<p className="text-sm text-destructive">{errors.district.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="area">Dien tich (m2) *</Label>
|
||||||
|
<Input
|
||||||
|
id="area"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="VD: 80"
|
||||||
|
{...register('area')}
|
||||||
|
/>
|
||||||
|
{errors.area && (
|
||||||
|
<p className="text-sm text-destructive">{errors.area.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Bedrooms + Bathrooms + Floors */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bedrooms">Phong ngu</Label>
|
||||||
|
<Input
|
||||||
|
id="bedrooms"
|
||||||
|
type="number"
|
||||||
|
placeholder="VD: 3"
|
||||||
|
{...register('bedrooms')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bathrooms">Phong tam</Label>
|
||||||
|
<Input
|
||||||
|
id="bathrooms"
|
||||||
|
type="number"
|
||||||
|
placeholder="VD: 2"
|
||||||
|
{...register('bathrooms')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="floors">So tang</Label>
|
||||||
|
<Input
|
||||||
|
id="floors"
|
||||||
|
type="number"
|
||||||
|
placeholder="VD: 4"
|
||||||
|
{...register('floors')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Frontage + Road Width + Year Built */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="frontage">Mat tien (m)</Label>
|
||||||
|
<Input
|
||||||
|
id="frontage"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="VD: 5"
|
||||||
|
{...register('frontage')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="roadWidth">Do rong duong (m)</Label>
|
||||||
|
<Input
|
||||||
|
id="roadWidth"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="VD: 8"
|
||||||
|
{...register('roadWidth')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="yearBuilt">Nam xay dung</Label>
|
||||||
|
<Input
|
||||||
|
id="yearBuilt"
|
||||||
|
type="number"
|
||||||
|
placeholder="VD: 2020"
|
||||||
|
{...register('yearBuilt')}
|
||||||
|
/>
|
||||||
|
</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')}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hasLegalPaper">Co so do/giay to hop phap</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
||||||
|
{isLoading ? 'Dang dinh gia...' : 'Dinh gia ngay'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
apps/web/components/valuation/valuation-history.tsx
Normal file
116
apps/web/components/valuation/valuation-history.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { ValuationHistoryItem } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ValuationHistoryProps {
|
||||||
|
items: ValuationHistoryItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
|
||||||
|
return num.toLocaleString('vi-VN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
APARTMENT: 'Can ho',
|
||||||
|
HOUSE: 'Nha rieng',
|
||||||
|
VILLA: 'Biet thu',
|
||||||
|
LAND: 'Dat nen',
|
||||||
|
OFFICE: 'Van phong',
|
||||||
|
SHOPHOUSE: 'Shophouse',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ValuationHistory({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
onSelect,
|
||||||
|
isLoading,
|
||||||
|
}: ValuationHistoryProps) {
|
||||||
|
const totalPages = Math.ceil(total / 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Lich su dinh gia</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{total} lan dinh gia truoc do
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
|
Dang tai...
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
|
Chua co lich su dinh gia
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className="flex w-full items-center gap-4 rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{PROPERTY_TYPE_LABELS[item.propertyType] || item.propertyType}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.district}, {item.city} · {item.area} m2
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-primary">
|
||||||
|
{formatPrice(item.estimatedPriceVND)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
Truoc
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Trang {page}/{totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Tiep
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/web/components/valuation/valuation-results.tsx
Normal file
142
apps/web/components/valuation/valuation-results.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { ValuationResult } from '@/lib/valuation-api';
|
||||||
|
|
||||||
|
interface ValuationResultsProps {
|
||||||
|
result: ValuationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`;
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
|
||||||
|
return num.toLocaleString('vi-VN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriceM2(price: number): string {
|
||||||
|
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`;
|
||||||
|
return `${price.toLocaleString('vi-VN')} d/m2`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValuationResults({ result }: ValuationResultsProps) {
|
||||||
|
const confidencePct = Math.round(result.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Main estimate */}
|
||||||
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardDescription>Gia uoc tinh boi AI</CardDescription>
|
||||||
|
<CardTitle className="text-3xl text-primary">
|
||||||
|
{formatPrice(result.estimatedPriceVND)} VND
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Do tin cay</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${confidencePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{confidencePct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Gia/m2</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold">{formatPriceM2(result.pricePerM2)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Khoang gia</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold">
|
||||||
|
{formatPrice(result.priceRangeLow)} - {formatPrice(result.priceRangeHigh)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Price drivers */}
|
||||||
|
{result.priceDrivers.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Yeu to anh huong gia</CardTitle>
|
||||||
|
<CardDescription>Cac yeu to chinh tac dong den gia tri bat dong san</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.priceDrivers.map((driver) => (
|
||||||
|
<div key={driver.feature} className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
driver.direction === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{driver.direction === 'positive' ? '+' : '-'}
|
||||||
|
{Math.abs(driver.impact).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">{driver.feature}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full ${
|
||||||
|
driver.direction === 'positive' ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(Math.abs(driver.impact), 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparables */}
|
||||||
|
{result.comparables.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Bat dong san tuong tu</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{result.comparables.length} bat dong san co dac diem tuong tu trong khu vuc
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.comparables.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="flex items-center gap-4 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{comp.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{comp.district} · {comp.areaM2} m2
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-primary">{formatPrice(Number(comp.priceVND))}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatPriceM2(comp.pricePerM2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<span className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
|
||||||
|
{Math.round(comp.similarity * 100)}% tuong tu
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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