feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
410
apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Normal file
410
apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ArrowRight, CheckCircle, FileText, Loader2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useGenerateReport } from '@/lib/hooks/use-reports';
|
||||
import type { ReportType } from '@/lib/reports-api';
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────
|
||||
|
||||
const PROVINCES = [
|
||||
'Hồ Chí Minh', 'Hà Nội', 'Đà Nẵng', 'Bình Dương', 'Đồng Nai',
|
||||
'Long An', 'Bà Rịa - Vũng Tàu', 'Bắc Ninh', 'Hải Phòng', 'Hải Dương',
|
||||
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
|
||||
];
|
||||
|
||||
const HCM_DISTRICTS = [
|
||||
'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
||||
'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
|
||||
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
||||
'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ',
|
||||
];
|
||||
|
||||
const PROPERTY_TYPES = [
|
||||
{ value: 'APARTMENT', label: 'Căn hộ' },
|
||||
{ value: 'HOUSE', label: 'Nhà phố' },
|
||||
{ value: 'VILLA', label: 'Biệt thự' },
|
||||
{ value: 'LAND', label: 'Đất nền' },
|
||||
{ value: 'COMMERCIAL', label: 'Thương mại' },
|
||||
];
|
||||
|
||||
// Wizard report types — subset users can create
|
||||
const WIZARD_REPORT_TYPES: ReportType[] = [
|
||||
'INDUSTRIAL_LOCATION',
|
||||
'RESIDENTIAL_MARKET',
|
||||
'DISTRICT_ANALYSIS',
|
||||
];
|
||||
|
||||
// ─── Steps ─────────────────────────────────────────────
|
||||
|
||||
type Step = 'select_type' | 'configure' | 'review';
|
||||
const STEPS: { key: Step; label: string }[] = [
|
||||
{ key: 'select_type', label: 'Chọn loại' },
|
||||
{ key: 'configure', label: 'Cấu hình' },
|
||||
{ key: 'review', label: 'Xác nhận' },
|
||||
];
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export default function TaoMoiPage() {
|
||||
const router = useRouter();
|
||||
const generateReport = useGenerateReport();
|
||||
|
||||
const [step, setStep] = React.useState<Step>('select_type');
|
||||
const [selectedType, setSelectedType] = React.useState<ReportType | null>(null);
|
||||
const [title, setTitle] = React.useState('');
|
||||
|
||||
// Params per type
|
||||
const [province, setProvince] = React.useState('');
|
||||
const [district, setDistrict] = React.useState('');
|
||||
const [propertyType, setPropertyType] = React.useState('');
|
||||
const [dateFrom, setDateFrom] = React.useState('');
|
||||
const [dateTo, setDateTo] = React.useState('');
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 'select_type':
|
||||
return !!selectedType;
|
||||
case 'configure':
|
||||
if (!title.trim()) return false;
|
||||
if (selectedType === 'INDUSTRIAL_LOCATION') return !!province;
|
||||
if (selectedType === 'RESIDENTIAL_MARKET') return !!district;
|
||||
if (selectedType === 'DISTRICT_ANALYSIS') return !!district;
|
||||
return true;
|
||||
case 'review':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildParams = (): Record<string, unknown> => {
|
||||
switch (selectedType) {
|
||||
case 'INDUSTRIAL_LOCATION':
|
||||
return { province };
|
||||
case 'RESIDENTIAL_MARKET':
|
||||
return {
|
||||
district,
|
||||
propertyType: propertyType || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
};
|
||||
case 'DISTRICT_ANALYSIS':
|
||||
return { district };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 'select_type') setStep('configure');
|
||||
else if (step === 'configure') setStep('review');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'configure') setStep('select_type');
|
||||
else if (step === 'review') setStep('configure');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedType) return;
|
||||
try {
|
||||
const result = await generateReport.mutateAsync({
|
||||
type: selectedType,
|
||||
title: title.trim(),
|
||||
params: buildParams(),
|
||||
});
|
||||
router.push(`/bao-cao/${result.reportId}`);
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTypeInfo = REPORT_TYPES.find((t) => t.value === selectedType);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold">Tạo báo cáo mới</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Chọn loại báo cáo và cấu hình thông số phân tích
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
{STEPS.map((s, i) => (
|
||||
<React.Fragment key={s.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
i < stepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: i === stepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i < stepIndex ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-sm font-medium sm:inline ${
|
||||
i <= stepIndex ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`mx-2 h-0.5 flex-1 ${
|
||||
i < stepIndex ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
{/* Step 1: Select type */}
|
||||
{step === 'select_type' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Chọn loại báo cáo</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{WIZARD_REPORT_TYPES.map((typeValue) => {
|
||||
const info = REPORT_TYPES.find((t) => t.value === typeValue);
|
||||
if (!info) return null;
|
||||
const Icon = info.icon;
|
||||
return (
|
||||
<button
|
||||
key={typeValue}
|
||||
className={`flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors ${
|
||||
selectedType === typeValue
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-muted-foreground/30'
|
||||
}`}
|
||||
onClick={() => setSelectedType(typeValue)}
|
||||
>
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{info.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
{step === 'configure' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Cấu hình báo cáo</h2>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">Tiêu đề báo cáo</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Nhập tiêu đề báo cáo..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedType === 'INDUSTRIAL_LOCATION' && (
|
||||
<div>
|
||||
<Label htmlFor="province">Tỉnh/Thành phố</Label>
|
||||
<select
|
||||
id="province"
|
||||
value={province}
|
||||
onChange={(e) => setProvince(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Chọn tỉnh/thành phố</option>
|
||||
{PROVINCES.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedType === 'RESIDENTIAL_MARKET' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="district">Quận/Huyện</Label>
|
||||
<select
|
||||
id="district"
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Chọn quận/huyện</option>
|
||||
{HCM_DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="propertyType">Loại bất động sản</Label>
|
||||
<select
|
||||
id="propertyType"
|
||||
value={propertyType}
|
||||
onChange={(e) => setPropertyType(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Tất cả loại</option>
|
||||
{PROPERTY_TYPES.map((pt) => (
|
||||
<option key={pt.value} value={pt.value}>{pt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dateFrom">Từ ngày</Label>
|
||||
<Input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dateTo">Đến ngày</Label>
|
||||
<Input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedType === 'DISTRICT_ANALYSIS' && (
|
||||
<div>
|
||||
<Label htmlFor="district-analysis">Quận/Huyện</Label>
|
||||
<select
|
||||
id="district-analysis"
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Chọn quận/huyện</option>
|
||||
{HCM_DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Review */}
|
||||
{step === 'review' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Xác nhận báo cáo</h2>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Loại báo cáo</span>
|
||||
<span className="text-sm font-medium">{selectedTypeInfo?.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tiêu đề</span>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
|
||||
{province && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tỉnh/Thành phố</span>
|
||||
<span className="text-sm font-medium">{province}</span>
|
||||
</div>
|
||||
)}
|
||||
{district && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Quận/Huyện</span>
|
||||
<span className="text-sm font-medium">{district}</span>
|
||||
</div>
|
||||
)}
|
||||
{propertyType && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Loại BĐS</span>
|
||||
<span className="text-sm font-medium">
|
||||
{PROPERTY_TYPES.find((pt) => pt.value === propertyType)?.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dateFrom && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Từ ngày</span>
|
||||
<span className="text-sm font-medium">{dateFrom}</span>
|
||||
</div>
|
||||
)}
|
||||
{dateTo && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Đến ngày</span>
|
||||
<span className="text-sm font-medium">{dateTo}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generateReport.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Không thể tạo báo cáo. Vui lòng thử lại.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={step === 'select_type'}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Quay lại
|
||||
</Button>
|
||||
|
||||
{step === 'review' ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={generateReport.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{generateReport.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
{generateReport.isPending ? 'Đang tạo...' : 'Tạo báo cáo'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="gap-2"
|
||||
>
|
||||
Tiếp tục
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user