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:
@@ -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ử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu 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ử lại sau.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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á bất động sản')).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('Loại bất động sản *')).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('Tỉnh/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('Quận/Huyện *')).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('Diện 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 tắm')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('So tang')).toBeInTheDocument();
|
expect(screen.getByLabelText('Số tầng')).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('Mặt tiền (m)')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Do rong duong (m)')).toBeInTheDocument();
|
expect(screen.getByLabelText('Độ rộng đườ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 dựng')).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ổ đỏ/giấy tờ hợp 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(/Nhập thông tin bất động sản để nhận ước tính giá từ AI/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('ValuationHistory', () => {
|
|||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('Lich su dinh gia')).toBeInTheDocument();
|
expect(screen.getByText('Lịch 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 lần đị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ó lịch 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 tải...')).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', () => {
|
||||||
|
|||||||
@@ -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('Bất động sản 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 bất động sản/)).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('Bất động sản tương tự')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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á bất động sản</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Nhap thong tin bat dong san de nhan uoc tinh gia tu AI
|
Nhập thông tin bất động sản để nhận ướ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">Loại bất động sản *</Label>
|
||||||
<Select id="propertyType" {...register('propertyType')}>
|
<Select id="propertyType" {...register('propertyType')}>
|
||||||
<option value="">-- Chon loai --</option>
|
<option value="">-- Chọn loại --</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">Tỉnh/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">Quận/Huyện *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="district"
|
id="district"
|
||||||
placeholder="VD: Quan 1, Binh Thanh..."
|
placeholder="VD: Quận 1, Bình Thạnh..."
|
||||||
{...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">Diện 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 tắm</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ố tầng</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">Mặt tiền (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">Độ rộng đườ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 dựng</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>
|
||||||
|
|||||||
@@ -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)} triệu`;
|
||||||
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: 'Biệt thự',
|
||||||
LAND: 'Dat nen',
|
LAND: 'Đất nền',
|
||||||
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">Lịch sử định giá</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{total} lan dinh gia truoc do
|
{total} lần đị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 tải...
|
||||||
</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ó lịch 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} · {item.area} m2
|
{item.district}, {item.city} · {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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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)} triệu`;
|
||||||
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 bởi 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 cậy</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">Khoảng 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ị bất động sản</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">Bất động sản tương tự</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{result.comparables.length} bat dong san co dac diem tuong tu trong khu vuc
|
{result.comparables.length} bất động sản có đặc điểm tương tự trong khu vực
|
||||||
</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} · {comp.areaM2} m2
|
{comp.district} · {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>
|
||||||
|
|||||||
@@ -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}`),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user