Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m31s
Deploy / Build API Image (push) Failing after 25s
E2E Tests / Playwright E2E (push) Failing after 23s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Deploy / Build Web Image (push) Failing after 17s
Deploy / Build AI Services Image (push) Failing after 13s
Security Scanning / Trivy Scan — Web Image (push) Failing after 58s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 51s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m55s
Security Scanning / Trivy Filesystem Scan (push) Failing after 45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 3s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Closes the last gap from the tec-2725 branch: the valuation form's v2
extended-features section and POST endpoint can now submit real
predictions through to the Python ensemble model.
Backend
- New DTO apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts
with all v1 fields + 8 v2 fields (useV2 toggle, distanceToHospital/Park/
Mall in km, floodZoneRisk enum NONE|LOW|MEDIUM|HIGH, hasElevator/
Parking/Pool booleans).
- New CQRS handler apps/api/src/modules/analytics/application/queries/
predict-valuation/ that routes to AVM_SERVICE.estimateValue() with the
full request body.
- Extend AVMParams (domain) with the same v2 fields + inline v1 fields
(district, city, bedrooms, bathrooms, floors, frontage, roadWidth,
hasLegalPaper, projectId, imageUrl, description, deepAnalysis).
- HttpAVMService.estimateViaAi now branches on `useV2`: v2 calls the new
aiClient.predictV2() → POST /avm/v2/predict on the Python service,
mapping floodZoneRisk enum → 0..1 float and computing
building_age_years from yearBuilt. v1 path gets all the inline
descriptors wired through so non-propertyId calls no longer lose
context.
- AiServiceClient gets AiPredictV2Request / AiPredictV2Response types
mirroring libs/ai-services/app/models/avm_v2.py::AVMv2PredictRequest
(which already accepts all 7 numeric/boolean v2 fields — no Python
change needed).
- Register PredictValuationHandler in AnalyticsModule.
- New route POST /analytics/valuation on AnalyticsController:
JwtAuthGuard + QuotaGuard + EndpointRateLimitGuard (10/min),
@RequireQuota('analytics_queries'), full Swagger doc. Total endpoint
count 179 → 180.
Frontend
- Extend ValuationRequest with useV2, 3 distance-km fields,
floodZoneRisk, hasElevator/Parking/Pool + export FloodZoneRisk type
and FLOOD_RISK_OPTIONS.
- valuationApi.predict() body mapping now includes v2 fields and renames
'areaM2' → 'area' to match the backend DTO contract.
- valuationFormSchema gains matching optional Zod fields + exports
FLOOD_RISK_OPTIONS for the form.
- valuation-form.tsx gets:
* Image upload hardening: MIME+size validation (JPG/PNG ≤5MB) before
preview, role="progressbar" + aria-labels on the progress bar,
role="alert" + data-testid="image-upload-error" on errors. Matches
the upload-progress part of the task/tec-2725 commit 4ee0129 that
was previously parked as blocked.
* New Sparkles-branded "Mô hình v2 (Ensemble)" toggle alongside the
existing Bot-branded "Phân tích chuyên sâu" toggle.
* Collapsible "Đặc trưng mở rộng (AVM v2)" section with distance
inputs, flood-risk select, and three amenity checkboxes.
* handleFormSubmit passes all v2 fields through to onSubmit.
Python service unchanged — AVMv2PredictRequest already has every field
we send (distance_to_hospital_km, flood_zone_risk as float,
has_elevator/parking/pool, etc.).
Typecheck clean for the valuation surface. Pre-existing errors in
metadata.spec.ts and transfer-wizard-client.tsx are unrelated and left
for a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
597 lines
22 KiB
TypeScript
597 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { Bot, ChevronDown, ImagePlus, Search, Sparkles, X } from 'lucide-react';
|
|
import { useCallback, useRef, useState } from 'react';
|
|
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 { useProjectSearch } from '@/lib/hooks/use-valuation';
|
|
import {
|
|
valuationFormSchema,
|
|
type ValuationFormData,
|
|
VALUATION_PROPERTY_TYPES,
|
|
CITIES,
|
|
FLOOD_RISK_OPTIONS,
|
|
} 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,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<ValuationFormData>({
|
|
resolver: zodResolver(valuationFormSchema),
|
|
defaultValues: {
|
|
city: 'Ho Chi Minh',
|
|
hasLegalPaper: true,
|
|
deepAnalysis: false,
|
|
},
|
|
});
|
|
|
|
// Project autocomplete state
|
|
const [projectQuery, setProjectQuery] = useState('');
|
|
const [projectName, setProjectName] = useState('');
|
|
const [showProjectDropdown, setShowProjectDropdown] = useState(false);
|
|
const { data: projectResults } = useProjectSearch(projectQuery);
|
|
const projectInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Image upload state
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// v2 section collapsible state
|
|
const [showV2Section, setShowV2Section] = useState(false);
|
|
|
|
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
setProjectQuery(value);
|
|
setProjectName(value);
|
|
setShowProjectDropdown(value.length >= 2);
|
|
if (!value) {
|
|
setValue('projectId', '');
|
|
}
|
|
}, [setValue]);
|
|
|
|
const handleSelectProject = useCallback(
|
|
(id: string, name: string) => {
|
|
setValue('projectId', id);
|
|
setProjectName(name);
|
|
setProjectQuery('');
|
|
setShowProjectDropdown(false);
|
|
},
|
|
[setValue],
|
|
);
|
|
|
|
const handleImageChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
setUploadError(null);
|
|
if (!file) return;
|
|
|
|
// Validate MIME type and size before anything else
|
|
const ALLOWED = ['image/jpeg', 'image/png'];
|
|
if (!ALLOWED.includes(file.type)) {
|
|
setUploadError('Chỉ chấp nhận ảnh JPG hoặc PNG.');
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
return;
|
|
}
|
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
if (file.size > MAX_BYTES) {
|
|
setUploadError('Ảnh vượt quá 5MB. Vui lòng chọn ảnh nhỏ hơn.');
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
return;
|
|
}
|
|
|
|
// Simulate progress during local processing (FileReader is synchronous
|
|
// for small files; real uploads to MinIO would track XHR progress).
|
|
setUploadProgress(0);
|
|
const reader = new FileReader();
|
|
reader.onprogress = (ev) => {
|
|
if (ev.lengthComputable) {
|
|
setUploadProgress(Math.round((ev.loaded / ev.total) * 100));
|
|
}
|
|
};
|
|
reader.onload = (ev) => {
|
|
setImagePreview(ev.target?.result as string);
|
|
setUploadProgress(100);
|
|
// Hide the bar after a tick so users see "100%" briefly
|
|
setTimeout(() => setUploadProgress(null), 400);
|
|
};
|
|
reader.onerror = () => {
|
|
setUploadError('Không thể đọc ảnh. Vui lòng thử lại.');
|
|
setUploadProgress(null);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// For now, use an object URL locally; presigned upload is a separate flow.
|
|
setImageUrl(URL.createObjectURL(file));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleClearImage = useCallback(() => {
|
|
setImagePreview(null);
|
|
setImageUrl(null);
|
|
setUploadError(null);
|
|
setUploadProgress(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}, []);
|
|
|
|
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,
|
|
projectId: data.projectId || undefined,
|
|
description: data.description || undefined,
|
|
deepAnalysis: data.deepAnalysis,
|
|
imageUrl: imageUrl || undefined,
|
|
// AVM v2
|
|
useV2: data.useV2,
|
|
distanceToHospitalKm: toNum(data.distanceToHospitalKm),
|
|
distanceToParkKm: toNum(data.distanceToParkKm),
|
|
distanceToMallKm: toNum(data.distanceToMallKm),
|
|
floodZoneRisk: data.floodZoneRisk,
|
|
hasElevator: data.hasElevator,
|
|
hasParking: data.hasParking,
|
|
hasPool: data.hasPool,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Định giá bất động sản</CardTitle>
|
|
<CardDescription>
|
|
Nhập thông tin bất động sản để nhận ước tính giá từ AI
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
|
{/* Project selector (autocomplete) */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="projectSearch">Dự án (tùy chọn)</Label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id="projectSearch"
|
|
ref={projectInputRef}
|
|
value={projectName}
|
|
onChange={handleProjectSearch}
|
|
onFocus={() => projectQuery.length >= 2 && setShowProjectDropdown(true)}
|
|
onBlur={() => setTimeout(() => setShowProjectDropdown(false), 200)}
|
|
placeholder="Tìm kiếm dự án..."
|
|
className="pl-9"
|
|
/>
|
|
{projectName && (
|
|
<button
|
|
type="button"
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
setProjectName('');
|
|
setProjectQuery('');
|
|
setValue('projectId', '');
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
{showProjectDropdown && projectResults?.data && projectResults.data.length > 0 && (
|
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-48 overflow-auto rounded-md border bg-popover p-1 shadow-md">
|
|
{projectResults.data.map((project) => (
|
|
<button
|
|
key={project.id}
|
|
type="button"
|
|
className="flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
|
onClick={() => handleSelectProject(project.id, project.name)}
|
|
>
|
|
<div>
|
|
<p className="font-medium">{project.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{project.district}, {project.city}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 1: Property type + City */}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="propertyType">Loại bất động sản *</Label>
|
|
<Select id="propertyType" {...register('propertyType')}>
|
|
<option value="">-- Chọn loại --</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">Tỉnh/Thành phố *</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">Quận/Huyện *</Label>
|
|
<Input
|
|
id="district"
|
|
placeholder="VD: Quận 1, Bình Thạnh..."
|
|
{...register('district')}
|
|
/>
|
|
{errors.district && (
|
|
<p className="text-sm text-destructive">{errors.district.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="area">Diện tích (m²) *</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">Phòng ngủ</Label>
|
|
<Input
|
|
id="bedrooms"
|
|
type="number"
|
|
placeholder="VD: 3"
|
|
{...register('bedrooms')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bathrooms">Phòng tắm</Label>
|
|
<Input
|
|
id="bathrooms"
|
|
type="number"
|
|
placeholder="VD: 2"
|
|
{...register('bathrooms')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="floors">Số tầng</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">Mặt tiền (m)</Label>
|
|
<Input
|
|
id="frontage"
|
|
type="number"
|
|
step="0.1"
|
|
placeholder="VD: 5"
|
|
{...register('frontage')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="roadWidth">Độ rộng đường (m)</Label>
|
|
<Input
|
|
id="roadWidth"
|
|
type="number"
|
|
step="0.1"
|
|
placeholder="VD: 8"
|
|
{...register('roadWidth')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="yearBuilt">Năm xây dựng</Label>
|
|
<Input
|
|
id="yearBuilt"
|
|
type="number"
|
|
placeholder="VD: 2020"
|
|
{...register('yearBuilt')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Image upload */}
|
|
<div className="space-y-2">
|
|
<Label>Hình ảnh (tùy chọn)</Label>
|
|
<div className="flex items-start gap-4">
|
|
{imagePreview ? (
|
|
<div className="relative h-24 w-24 overflow-hidden rounded-lg border">
|
|
<img
|
|
src={imagePreview}
|
|
alt="Ảnh bất động sản"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleClearImage}
|
|
className="absolute right-1 top-1 rounded-full bg-background/80 p-0.5 hover:bg-background"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-muted-foreground/25 text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground"
|
|
>
|
|
<ImagePlus className="h-6 w-6" />
|
|
<span className="text-xs">Tải ảnh</span>
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png"
|
|
className="hidden"
|
|
onChange={handleImageChange}
|
|
/>
|
|
<div className="flex-1 space-y-1">
|
|
<p className="text-xs text-muted-foreground">
|
|
Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB)
|
|
</p>
|
|
{uploadProgress != null && (
|
|
<div
|
|
role="progressbar"
|
|
aria-label="Tiến độ tải ảnh"
|
|
aria-valuenow={uploadProgress}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
|
|
>
|
|
<div
|
|
className="h-full bg-primary transition-all"
|
|
style={{ width: `${uploadProgress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{uploadError && (
|
|
<p
|
|
role="alert"
|
|
data-testid="image-upload-error"
|
|
className="text-xs text-destructive"
|
|
>
|
|
{uploadError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description textarea */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Mô tả thêm (tùy chọn)</Label>
|
|
<textarea
|
|
id="description"
|
|
{...register('description')}
|
|
rows={3}
|
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
placeholder="Mô tả chi tiết về bất động sản: vị trí, hướng, tình trạng, tiện ích lân cận..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Toggles row */}
|
|
<div className="flex flex-wrap items-center gap-6">
|
|
<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">Có sổ đỏ/giấy tờ hợp pháp</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="deepAnalysis"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-input accent-primary"
|
|
{...register('deepAnalysis')}
|
|
/>
|
|
<Label htmlFor="deepAnalysis" className="flex items-center gap-1.5">
|
|
<Bot className="h-4 w-4 text-primary" />
|
|
Phân tích chuyên sâu
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="useV2"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-input accent-primary"
|
|
{...register('useV2')}
|
|
/>
|
|
<Label htmlFor="useV2" className="flex items-center gap-1.5">
|
|
<Sparkles className="h-4 w-4 text-primary" />
|
|
Mô hình v2 (Ensemble)
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AVM v2 — Extended features (collapsible) */}
|
|
<div className="rounded-lg border bg-muted/30">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowV2Section(!showV2Section)}
|
|
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
|
|
aria-expanded={showV2Section}
|
|
>
|
|
<span>Đặc trưng mở rộng (AVM v2)</span>
|
|
<ChevronDown
|
|
className={`h-4 w-4 transition-transform ${showV2Section ? 'rotate-180' : ''}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
{showV2Section && (
|
|
<div className="space-y-4 border-t p-4">
|
|
<p className="text-xs text-muted-foreground">
|
|
Các trường tùy chọn, chỉ sử dụng khi bật “Mô hình v2”. Bỏ trống sẽ dùng giá trị mặc định.
|
|
</p>
|
|
|
|
{/* Distances */}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="distanceToHospitalKm">Đến bệnh viện (km)</Label>
|
|
<Input
|
|
id="distanceToHospitalKm"
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
{...register('distanceToHospitalKm')}
|
|
placeholder="1.5"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="distanceToParkKm">Đến công viên (km)</Label>
|
|
<Input
|
|
id="distanceToParkKm"
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
{...register('distanceToParkKm')}
|
|
placeholder="0.8"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="distanceToMallKm">Đến TTTM (km)</Label>
|
|
<Input
|
|
id="distanceToMallKm"
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
{...register('distanceToMallKm')}
|
|
placeholder="2.0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Flood zone + amenity checkboxes */}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="floodZoneRisk">Nguy cơ ngập</Label>
|
|
<Select id="floodZoneRisk" {...register('floodZoneRisk')}>
|
|
<option value="">-- Không xác định --</option>
|
|
{FLOOD_RISK_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col justify-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="hasElevator"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-input"
|
|
{...register('hasElevator')}
|
|
/>
|
|
<Label htmlFor="hasElevator">Có thang máy</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="hasParking"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-input"
|
|
{...register('hasParking')}
|
|
/>
|
|
<Label htmlFor="hasParking">Có bãi đậu xe</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="hasPool"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-input"
|
|
{...register('hasPool')}
|
|
/>
|
|
<Label htmlFor="hasPool">Có hồ bơi</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
|
{isLoading ? 'Đang định giá...' : 'Định giá ngay'}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|