- Add @@unique([subscriptionId, metric, periodStart, periodEnd]) constraint to UsageRecord model with corresponding migration - Replace racy findFirst+update/create pattern with Prisma upsert using INSERT ON CONFLICT DO UPDATE SET count = count + delta - Fix CheckQuotaHandler to use period-scoped findUnique instead of unscoped findFirst, preventing stale cross-period reads - Update tests to reflect atomic upsert pattern Closes GOO-4 Co-Authored-By: Paperclip <noreply@paperclip.ing>
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
'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';
|
|
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
|
|
|
|
// ─── 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_LIST = HCM_DISTRICTS;
|
|
|
|
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_LIST.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_LIST.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>
|
|
);
|
|
}
|