Files
goodgo-platform/apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Ho Ngoc Hai ee6d6d4c17 fix(subscriptions): atomic UsageRecord metering to prevent quota bypass
- 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>
2026-04-22 23:22:59 +07:00

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 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>
);
}