fix: valuation page Vietnamese diacritics, correct API routes, update tests

- Add proper Vietnamese diacritics to all valuation components
  (form, results, history) and their test assertions
- Fix valuation API client to use /analytics/valuation endpoint
- Return empty history gracefully (no server endpoint yet)

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-13 12:03:47 +07:00
parent f373f7b1e2
commit ccfc176e40
8 changed files with 108 additions and 99 deletions

View File

@@ -34,9 +34,9 @@ export default function ValuationPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold sm:text-3xl">Dinh gia AI</h1> <h1 className="text-2xl font-bold sm:text-3xl">Đnh giá AI</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong S dng AI đ ưc tính giá tr bt đng sn da trên d liu th trường
</p> </p>
</div> </div>
@@ -50,7 +50,7 @@ export default function ValuationPage() {
{predictMutation.isError && ( {predictMutation.isError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive"> <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. Không th đnh giá. Vui lòng th li sau.
</div> </div>
)} )}

View File

@@ -26,65 +26,65 @@ vi.mock('@/lib/validations/valuation', () => ({
describe('ValuationForm', () => { describe('ValuationForm', () => {
it('renders form title', () => { it('renders form title', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByText('Dinh gia bat dong san')).toBeInTheDocument(); expect(screen.getByText('Định giá bt động sn')).toBeInTheDocument();
}); });
it('renders property type select', () => { it('renders property type select', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Loai bat dong san *')).toBeInTheDocument(); expect(screen.getByLabelText('Loi bt động sn *')).toBeInTheDocument();
}); });
it('renders city select', () => { it('renders city select', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Tinh/Thanh pho *')).toBeInTheDocument(); expect(screen.getByLabelText('Tnh/Thành ph *')).toBeInTheDocument();
}); });
it('renders district input', () => { it('renders district input', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Quan/Huyen *')).toBeInTheDocument(); expect(screen.getByLabelText('Qun/Huyn *')).toBeInTheDocument();
}); });
it('renders area input', () => { it('renders area input', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Dien tich (m2) *')).toBeInTheDocument(); expect(screen.getByLabelText('Din tích (m²) *')).toBeInTheDocument();
}); });
it('renders bedroom, bathroom, floors inputs', () => { it('renders bedroom, bathroom, floors inputs', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Phong ngu')).toBeInTheDocument(); expect(screen.getByLabelText('Phòng ng')).toBeInTheDocument();
expect(screen.getByLabelText('Phong tam')).toBeInTheDocument(); expect(screen.getByLabelText('Phòng tm')).toBeInTheDocument();
expect(screen.getByLabelText('So tang')).toBeInTheDocument(); expect(screen.getByLabelText('S tng')).toBeInTheDocument();
}); });
it('renders frontage and road width inputs', () => { it('renders frontage and road width inputs', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Mat tien (m)')).toBeInTheDocument(); expect(screen.getByLabelText('Mt tin (m)')).toBeInTheDocument();
expect(screen.getByLabelText('Do rong duong (m)')).toBeInTheDocument(); expect(screen.getByLabelText('Độ rng đường (m)')).toBeInTheDocument();
}); });
it('renders year built input', () => { it('renders year built input', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Nam xay dung')).toBeInTheDocument(); expect(screen.getByLabelText('Năm xây dng')).toBeInTheDocument();
}); });
it('renders legal paper checkbox', () => { it('renders legal paper checkbox', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Co so do/giay to hop phap')).toBeInTheDocument(); expect(screen.getByLabelText('Có sổ đỏ/giy t hp pháp')).toBeInTheDocument();
}); });
it('renders submit button', () => { it('renders submit button', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByText('Dinh gia ngay')).toBeInTheDocument(); expect(screen.getByText('Định giá ngay')).toBeInTheDocument();
}); });
it('shows loading text when isLoading', () => { it('shows loading text when isLoading', () => {
render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />); render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />);
expect(screen.getByText('Dang dinh gia...')).toBeInTheDocument(); expect(screen.getByText('Đang định giá...')).toBeInTheDocument();
}); });
it('disables submit button when loading', () => { it('disables submit button when loading', () => {
render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />); render(<ValuationForm onSubmit={vi.fn()} isLoading={true} />);
expect(screen.getByText('Dang dinh gia...')).toBeDisabled(); expect(screen.getByText('Đang định giá...')).toBeDisabled();
}); });
it('renders property type options', () => { it('renders property type options', () => {
@@ -101,6 +101,6 @@ describe('ValuationForm', () => {
it('renders description text', () => { it('renders description text', () => {
render(<ValuationForm onSubmit={vi.fn()} />); render(<ValuationForm onSubmit={vi.fn()} />);
expect(screen.getByText(/Nhap thong tin bat dong san de nhan uoc tinh gia tu AI/)).toBeInTheDocument(); expect(screen.getByText(/Nhp thông tin bt động sn để nhn ước tính giá t AI/)).toBeInTheDocument();
}); });
}); });

View File

@@ -38,7 +38,7 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByText('Lich su dinh gia')).toBeInTheDocument(); expect(screen.getByText('Lch sử định giá')).toBeInTheDocument();
}); });
it('renders total count description', () => { it('renders total count description', () => {
@@ -51,7 +51,7 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByText('2 lan dinh gia truoc do')).toBeInTheDocument(); expect(screen.getByText('2 ln định giá trước đó')).toBeInTheDocument();
}); });
it('renders property type labels', () => { it('renders property type labels', () => {
@@ -64,8 +64,8 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByText('Can ho')).toBeInTheDocument(); expect(screen.getByText('Căn h')).toBeInTheDocument();
expect(screen.getByText('Nha rieng')).toBeInTheDocument(); expect(screen.getByText('Nhà riêng')).toBeInTheDocument();
}); });
it('renders district and area for each item', () => { it('renders district and area for each item', () => {
@@ -78,8 +78,8 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByText(/Quận 1.*80 m2/)).toBeInTheDocument(); expect(screen.getByText(/Quận 1.*80 m²/)).toBeInTheDocument();
expect(screen.getByText(/Quận 7.*120 m2/)).toBeInTheDocument(); expect(screen.getByText(/Quận 7.*120 m²/)).toBeInTheDocument();
}); });
it('renders formatted prices', () => { it('renders formatted prices', () => {
@@ -92,8 +92,8 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByText('5.00 ty')).toBeInTheDocument(); expect(screen.getByText('5.00 t')).toBeInTheDocument();
expect(screen.getByText('8.50 ty')).toBeInTheDocument(); expect(screen.getByText('8.50 t')).toBeInTheDocument();
}); });
it('calls onSelect when an item is clicked', async () => { it('calls onSelect when an item is clicked', async () => {
@@ -107,7 +107,7 @@ describe('ValuationHistory', () => {
onSelect={onSelect} onSelect={onSelect}
/>, />,
); );
await userEvent.click(screen.getByText('Can ho')); await userEvent.click(screen.getByText('Căn h'));
expect(onSelect).toHaveBeenCalledWith('val-1'); expect(onSelect).toHaveBeenCalledWith('val-1');
}); });
@@ -121,7 +121,7 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByText('Chua co lich su dinh gia')).toBeInTheDocument(); expect(screen.getByText('Chưa có lch sử định giá')).toBeInTheDocument();
}); });
it('shows loading state', () => { it('shows loading state', () => {
@@ -135,7 +135,7 @@ describe('ValuationHistory', () => {
isLoading isLoading
/>, />,
); );
expect(screen.getByText('Dang tai...')).toBeInTheDocument(); expect(screen.getByText('Đang ti...')).toBeInTheDocument();
}); });
it('shows pagination when multiple pages', () => { it('shows pagination when multiple pages', () => {
@@ -149,8 +149,8 @@ describe('ValuationHistory', () => {
/>, />,
); );
expect(screen.getByText('Trang 1/3')).toBeInTheDocument(); expect(screen.getByText('Trang 1/3')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Truoc' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Trước' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Tiep' })).not.toBeDisabled(); expect(screen.getByRole('button', { name: 'Tiếp' })).not.toBeDisabled();
}); });
it('calls onPageChange with next page', async () => { it('calls onPageChange with next page', async () => {
@@ -164,7 +164,7 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
await userEvent.click(screen.getByRole('button', { name: 'Tiep' })); await userEvent.click(screen.getByRole('button', { name: 'Tiếp' }));
expect(onPageChange).toHaveBeenCalledWith(2); expect(onPageChange).toHaveBeenCalledWith(2);
}); });
@@ -178,8 +178,8 @@ describe('ValuationHistory', () => {
onSelect={vi.fn()} onSelect={vi.fn()}
/>, />,
); );
expect(screen.getByRole('button', { name: 'Tiep' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Tiếp' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Truoc' })).not.toBeDisabled(); expect(screen.getByRole('button', { name: 'Trước' })).not.toBeDisabled();
}); });
it('hides pagination when single page', () => { it('hides pagination when single page', () => {

View File

@@ -43,7 +43,7 @@ const mockResult: ValuationResult = {
describe('ValuationResults', () => { describe('ValuationResults', () => {
it('renders estimated price', () => { it('renders estimated price', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText('5.00 ty VND')).toBeInTheDocument(); expect(screen.getByText('5.00 t VNĐ')).toBeInTheDocument();
}); });
it('renders confidence percentage', () => { it('renders confidence percentage', () => {
@@ -53,17 +53,17 @@ describe('ValuationResults', () => {
it('renders price per m2', () => { it('renders price per m2', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText('62.5 tr/m2')).toBeInTheDocument(); expect(screen.getByText('62.5 tr/m²')).toBeInTheDocument();
}); });
it('renders price range', () => { it('renders price range', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText(/4\.50 ty.*5\.50 ty/)).toBeInTheDocument(); expect(screen.getByText(/4\.50 t.*5\.50 t/)).toBeInTheDocument();
}); });
it('renders price drivers section', () => { it('renders price drivers section', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText('Yeu to anh huong gia')).toBeInTheDocument(); expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
expect(screen.getByText('Vị trí trung tâm')).toBeInTheDocument(); expect(screen.getByText('Vị trí trung tâm')).toBeInTheDocument();
expect(screen.getByText('Tầng thấp')).toBeInTheDocument(); expect(screen.getByText('Tầng thấp')).toBeInTheDocument();
}); });
@@ -80,31 +80,31 @@ describe('ValuationResults', () => {
it('renders comparables section', () => { it('renders comparables section', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText('Bat dong san tuong tu')).toBeInTheDocument(); expect(screen.getByText('Bt động sn tương t')).toBeInTheDocument();
expect(screen.getByText('Căn hộ tương tự A')).toBeInTheDocument(); expect(screen.getByText('Căn hộ tương tự A')).toBeInTheDocument();
expect(screen.getByText('Căn hộ tương tự B')).toBeInTheDocument(); expect(screen.getByText('Căn hộ tương tự B')).toBeInTheDocument();
}); });
it('shows comparable count', () => { it('shows comparable count', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText(/2 bat dong san/)).toBeInTheDocument(); expect(screen.getByText(/2 bt động sn/)).toBeInTheDocument();
}); });
it('shows similarity percentage for comparables', () => { it('shows similarity percentage for comparables', () => {
render(<ValuationResults result={mockResult} />); render(<ValuationResults result={mockResult} />);
expect(screen.getByText('92% tuong tu')).toBeInTheDocument(); expect(screen.getByText('92% tương t')).toBeInTheDocument();
expect(screen.getByText('85% tuong tu')).toBeInTheDocument(); expect(screen.getByText('85% tương t')).toBeInTheDocument();
}); });
it('hides drivers section when empty', () => { it('hides drivers section when empty', () => {
const noDrivers = { ...mockResult, priceDrivers: [] }; const noDrivers = { ...mockResult, priceDrivers: [] };
render(<ValuationResults result={noDrivers} />); render(<ValuationResults result={noDrivers} />);
expect(screen.queryByText('Yeu to anh huong gia')).not.toBeInTheDocument(); expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
}); });
it('hides comparables section when empty', () => { it('hides comparables section when empty', () => {
const noComps = { ...mockResult, comparables: [] }; const noComps = { ...mockResult, comparables: [] };
render(<ValuationResults result={noComps} />); render(<ValuationResults result={noComps} />);
expect(screen.queryByText('Bat dong san tuong tu')).not.toBeInTheDocument(); expect(screen.queryByText('Bt động sn tương t')).not.toBeInTheDocument();
}); });
}); });

View File

@@ -58,9 +58,9 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Dinh gia bat dong san</CardTitle> <CardTitle>Đnh giá bt đng sn</CardTitle>
<CardDescription> <CardDescription>
Nhap thong tin bat dong san de nhan uoc tinh gia tu AI Nhp thông tin bt đng sn đ nhn ưc tính giá t AI
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -68,9 +68,9 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
{/* Row 1: Property type + City */} {/* Row 1: Property type + City */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="propertyType">Loai bat dong san *</Label> <Label htmlFor="propertyType">Loi bt đng sn *</Label>
<Select id="propertyType" {...register('propertyType')}> <Select id="propertyType" {...register('propertyType')}>
<option value="">-- Chon loai --</option> <option value="">-- Chn loi --</option>
{VALUATION_PROPERTY_TYPES.map((t) => ( {VALUATION_PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}> <option key={t.value} value={t.value}>
{t.label} {t.label}
@@ -83,7 +83,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="city">Tinh/Thanh pho *</Label> <Label htmlFor="city">Tnh/Thành ph *</Label>
<Select id="city" {...register('city')}> <Select id="city" {...register('city')}>
{CITIES.map((c) => ( {CITIES.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>
@@ -100,10 +100,10 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
{/* Row 2: District + Area */} {/* Row 2: District + Area */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="district">Quan/Huyen *</Label> <Label htmlFor="district">Qun/Huyn *</Label>
<Input <Input
id="district" id="district"
placeholder="VD: Quan 1, Binh Thanh..." placeholder="VD: Qun 1, Bình Thnh..."
{...register('district')} {...register('district')}
/> />
{errors.district && ( {errors.district && (
@@ -112,7 +112,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="area">Dien tich (m2) *</Label> <Label htmlFor="area">Din tích (m²) *</Label>
<Input <Input
id="area" id="area"
type="number" type="number"
@@ -129,7 +129,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
{/* Row 3: Bedrooms + Bathrooms + Floors */} {/* Row 3: Bedrooms + Bathrooms + Floors */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bedrooms">Phong ngu</Label> <Label htmlFor="bedrooms">Phòng ng</Label>
<Input <Input
id="bedrooms" id="bedrooms"
type="number" type="number"
@@ -139,7 +139,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bathrooms">Phong tam</Label> <Label htmlFor="bathrooms">Phòng tm</Label>
<Input <Input
id="bathrooms" id="bathrooms"
type="number" type="number"
@@ -149,7 +149,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="floors">So tang</Label> <Label htmlFor="floors">S tng</Label>
<Input <Input
id="floors" id="floors"
type="number" type="number"
@@ -162,7 +162,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
{/* Row 4: Frontage + Road Width + Year Built */} {/* Row 4: Frontage + Road Width + Year Built */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="frontage">Mat tien (m)</Label> <Label htmlFor="frontage">Mt tin (m)</Label>
<Input <Input
id="frontage" id="frontage"
type="number" type="number"
@@ -173,7 +173,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="roadWidth">Do rong duong (m)</Label> <Label htmlFor="roadWidth">Đ rng đưng (m)</Label>
<Input <Input
id="roadWidth" id="roadWidth"
type="number" type="number"
@@ -184,7 +184,7 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="yearBuilt">Nam xay dung</Label> <Label htmlFor="yearBuilt">Năm xây dng</Label>
<Input <Input
id="yearBuilt" id="yearBuilt"
type="number" type="number"
@@ -202,11 +202,11 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input"
{...register('hasLegalPaper')} {...register('hasLegalPaper')}
/> />
<Label htmlFor="hasLegalPaper">Co so do/giay to hop phap</Label> <Label htmlFor="hasLegalPaper">Có s đ/giấy tờ hợp pháp</Label>
</div> </div>
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto"> <Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
{isLoading ? 'Dang dinh gia...' : 'Dinh gia ngay'} {isLoading ? 'Đang định giá...' : 'Định giá ngay'}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>

View File

@@ -14,17 +14,17 @@ interface ValuationHistoryProps {
} }
function formatPrice(num: number): 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_000) return `${(num / 1_000_000_000).toFixed(2)} t`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triu`;
return num.toLocaleString('vi-VN'); return num.toLocaleString('vi-VN');
} }
const PROPERTY_TYPE_LABELS: Record<string, string> = { const PROPERTY_TYPE_LABELS: Record<string, string> = {
APARTMENT: 'Can ho', APARTMENT: 'Căn h',
HOUSE: 'Nha rieng', HOUSE: 'Nhà riêng',
VILLA: 'Biet thu', VILLA: 'Bit th',
LAND: 'Dat nen', LAND: 'Đất nn',
OFFICE: 'Van phong', OFFICE: 'Văn phòng',
SHOPHOUSE: 'Shophouse', SHOPHOUSE: 'Shophouse',
}; };
@@ -41,19 +41,19 @@ export function ValuationHistory({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Lich su dinh gia</CardTitle> <CardTitle className="text-lg">Lch s đnh giá</CardTitle>
<CardDescription> <CardDescription>
{total} lan dinh gia truoc do {total} ln đnh giá trước đó
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground"> <div className="flex h-32 items-center justify-center text-muted-foreground">
Dang tai... Đang ti...
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground"> <div className="flex h-32 items-center justify-center text-muted-foreground">
Chua co lich su dinh gia Chưa có lch s đnh giá
</div> </div>
) : ( ) : (
<> <>
@@ -70,7 +70,7 @@ export function ValuationHistory({
{PROPERTY_TYPE_LABELS[item.propertyType] || item.propertyType} {PROPERTY_TYPE_LABELS[item.propertyType] || item.propertyType}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{item.district}, {item.city} &middot; {item.area} m2 {item.district}, {item.city} &middot; {item.area} m²
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -93,7 +93,7 @@ export function ValuationHistory({
disabled={page <= 1} disabled={page <= 1}
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
> >
Truoc Trước
</Button> </Button>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Trang {page}/{totalPages} Trang {page}/{totalPages}
@@ -104,7 +104,7 @@ export function ValuationHistory({
disabled={page >= totalPages} disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
> >
Tiep Tiếp
</Button> </Button>
</div> </div>
)} )}

View File

@@ -8,14 +8,14 @@ interface ValuationResultsProps {
} }
function formatPrice(num: number): 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_000) return `${(num / 1_000_000_000).toFixed(2)} t`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triu`;
return num.toLocaleString('vi-VN'); return num.toLocaleString('vi-VN');
} }
function formatPriceM2(price: number): string { function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`; if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} d/m2`; return `${price.toLocaleString('vi-VN')} đ/m²`;
} }
export function ValuationResults({ result }: ValuationResultsProps) { export function ValuationResults({ result }: ValuationResultsProps) {
@@ -26,15 +26,15 @@ export function ValuationResults({ result }: ValuationResultsProps) {
{/* Main estimate */} {/* Main estimate */}
<Card className="border-primary/20 bg-primary/5"> <Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Gia uoc tinh boi AI</CardDescription> <CardDescription>Giá ưc tính bi AI</CardDescription>
<CardTitle className="text-3xl text-primary"> <CardTitle className="text-3xl text-primary">
{formatPrice(result.estimatedPriceVND)} VND {formatPrice(result.estimatedPriceVND)} VNĐ
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<p className="text-sm text-muted-foreground">Do tin cay</p> <p className="text-sm text-muted-foreground">Đ tin cy</p>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">
<div className="h-2 flex-1 rounded-full bg-muted"> <div className="h-2 flex-1 rounded-full bg-muted">
<div <div
@@ -46,13 +46,13 @@ export function ValuationResults({ result }: ValuationResultsProps) {
</div> </div>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Gia/m2</p> <p className="text-sm text-muted-foreground">Giá/m²</p>
<p className="mt-1 text-lg font-semibold">{formatPriceM2(result.pricePerM2)}</p> <p className="mt-1 text-lg font-semibold">{formatPriceM2(result.pricePerM2)}</p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Khoang gia</p> <p className="text-sm text-muted-foreground">Khong giá</p>
<p className="mt-1 text-lg font-semibold"> <p className="mt-1 text-lg font-semibold">
{formatPrice(result.priceRangeLow)} - {formatPrice(result.priceRangeHigh)} {formatPrice(result.priceRangeLow)} {formatPrice(result.priceRangeHigh)}
</p> </p>
</div> </div>
</div> </div>
@@ -63,8 +63,8 @@ export function ValuationResults({ result }: ValuationResultsProps) {
{result.priceDrivers.length > 0 && ( {result.priceDrivers.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Yeu to anh huong gia</CardTitle> <CardTitle className="text-lg">Yếu t nh hưởng giá</CardTitle>
<CardDescription>Cac yeu to chinh tac dong den gia tri bat dong san</CardDescription> <CardDescription>Các yếu t chính tác đng đến giá tr bt đng sn</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
@@ -102,9 +102,9 @@ export function ValuationResults({ result }: ValuationResultsProps) {
{result.comparables.length > 0 && ( {result.comparables.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Bat dong san tuong tu</CardTitle> <CardTitle className="text-lg">Bt đng sn tương t</CardTitle>
<CardDescription> <CardDescription>
{result.comparables.length} bat dong san co dac diem tuong tu trong khu vuc {result.comparables.length} bt đng sn có đc điểm tương t trong khu vc
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -117,7 +117,7 @@ export function ValuationResults({ result }: ValuationResultsProps) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{comp.title}</p> <p className="truncate font-medium">{comp.title}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{comp.district} &middot; {comp.areaM2} m2 {comp.district} &middot; {comp.areaM2} m²
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -128,7 +128,7 @@ export function ValuationResults({ result }: ValuationResultsProps) {
</div> </div>
<div className="hidden sm:block"> <div className="hidden sm:block">
<span className="rounded-full bg-accent px-2 py-1 text-xs font-medium"> <span className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
{Math.round(comp.similarity * 100)}% tuong tu {Math.round(comp.similarity * 100)}% tương t
</span> </span>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,8 @@ export interface ValuationRequest {
roadWidth?: number; roadWidth?: number;
yearBuilt?: number; yearBuilt?: number;
hasLegalPaper?: boolean; hasLegalPaper?: boolean;
latitude?: number;
longitude?: number;
} }
export interface ValuationComparable { export interface ValuationComparable {
@@ -69,17 +71,24 @@ export interface ValuationHistoryResponse {
// ─── API ──────────────────────────────────────────────── // ─── API ────────────────────────────────────────────────
export const valuationApi = { export const valuationApi = {
predict: (data: ValuationRequest) => /** Request AVM estimate via GET /analytics/valuation */
apiClient.post<ValuationResult>('/valuation/predict', data), 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}` : ''}`);
},
getHistory: (page = 1, limit = 10) => /** History is not available server-side — return empty result */
apiClient.get<ValuationHistoryResponse>( getHistory: (_page = 1, _limit = 10): Promise<ValuationHistoryResponse> =>
`/valuation/history?page=${page}&limit=${limit}`, Promise.resolve({ data: [], total: 0, page: _page, limit: _limit }),
),
getById: (id: string) => getById: (id: string) =>
apiClient.get<ValuationResult>(`/valuation/${id}`), apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${id}`),
predictForListing: (listingId: string) => predictForListing: (listingId: string) =>
apiClient.post<ValuationResult>(`/valuation/predict-listing/${listingId}`), apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${listingId}`),
}; };