feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -0,0 +1,346 @@
'use client';
import {
ArrowLeft,
Download,
Loader2,
AlertTriangle,
FileText,
} from 'lucide-react';
import { useParams } from 'next/navigation';
import * as React from 'react';
import { ReportChartsGrid } from '@/components/reports/report-chart';
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
import { ReportTypeBadge } from '@/components/reports/report-type-badge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { useReport, useReportStatus } from '@/lib/hooks/use-reports';
// ─── Types for report content ──────────────────────────
interface SectionData {
title?: string;
content?: string;
data?: Record<string, Array<{ period: string; value: number; unit: string }>>;
charts?: Record<string, Array<{ period: string; value: number; unit: string }>>;
projects?: Array<Record<string, unknown>>;
summary?: Record<string, unknown>;
}
// ─── Component ─────────────────────────────────────────
export default function BaoCaoDetailPage() {
const params = useParams<{ id: string }>();
const reportId = params.id;
const { data: report, isLoading, isError, refetch } = useReport(reportId);
// Poll status while generating
const isGenerating = report?.status === 'GENERATING';
const { data: statusData } = useReportStatus(
isGenerating ? reportId : null,
isGenerating,
);
// Refetch full report when status changes to READY
React.useEffect(() => {
if (statusData?.status === 'READY' || statusData?.status === 'FAILED') {
refetch();
}
}, [statusData?.status, refetch]);
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (isError || !report) {
return (
<div className="mx-auto max-w-4xl px-4 py-12 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Không tìm thấy báo cáo</p>
<p className="mt-1 text-sm text-muted-foreground">
Báo cáo không tồn tại hoặc đã bị xóa.
</p>
<Link href="/bao-cao">
<Button variant="outline" className="mt-4 gap-2">
<ArrowLeft className="h-4 w-4" />
Quay lại danh sách
</Button>
</Link>
</div>
);
}
const createdDate = new Date(report.createdAt).toLocaleDateString('vi-VN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const sections = (report.content?.['sections'] as Record<string, SectionData>) ?? {};
return (
<div className="mx-auto max-w-4xl px-4 py-6">
{/* Back link */}
<Link
href="/bao-cao"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Danh sách báo cáo
</Link>
{/* Header */}
<div className="mb-6 flex items-start justify-between">
<div>
<div className="mb-2 flex items-center gap-2">
<ReportTypeBadge type={report.type} />
<ReportStatusBadge status={report.status} />
</div>
<h1 className="text-2xl font-bold md:text-3xl">{report.title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{createdDate}</p>
</div>
{report.pdfUrl && (
<a href={report.pdfUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2">
<Download className="h-4 w-4" />
Tải PDF
</Button>
</a>
)}
</div>
{/* Generating state */}
{report.status === 'GENERATING' && (
<div className="rounded-lg border bg-blue-50 p-8 text-center">
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
<p className="mt-4 text-lg font-medium text-blue-900">
Đang tạo báo cáo...
</p>
<p className="mt-1 text-sm text-blue-700">
Hệ thống AI đang phân tích dữ liệu tạo báo cáo. Quá trình này
thể mất 1-3 phút.
</p>
</div>
)}
{/* Failed state */}
{report.status === 'FAILED' && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-8 text-center">
<AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
<p className="mt-4 text-lg font-medium text-destructive">
Tạo báo cáo thất bại
</p>
{report.errorMsg && (
<p className="mt-1 text-sm text-destructive/80">
{report.errorMsg}
</p>
)}
<Link href="/bao-cao/tao-moi">
<Button variant="outline" className="mt-4 gap-2">
<FileText className="h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
)}
{/* Report content */}
{report.status === 'READY' && report.content && (
<div className="space-y-8">
{Object.entries(sections).map(([key, section]) => (
<ReportSection key={key} sectionKey={key} section={section} />
))}
</div>
)}
</div>
);
}
// ─── Section renderer ──────────────────────────────────
function ReportSection({
sectionKey,
section,
}: {
sectionKey: string;
section: SectionData;
}) {
const title = section.title || sectionKey;
return (
<section className="rounded-lg border bg-card p-6">
<h2 className="mb-4 text-xl font-bold">{title}</h2>
{/* Narrative text */}
{section.content && (
<div className="prose prose-sm max-w-none text-foreground">
{section.content.split('\n').map((paragraph, i) => (
<p key={i} className="mb-2 last:mb-0">
{paragraph}
</p>
))}
</div>
)}
{/* Charts */}
{section.charts && <ReportChartsGrid charts={section.charts} />}
{/* Data tables */}
{section.data && <DataTablesSection data={section.data} />}
{/* Infrastructure projects */}
{section.projects && section.projects.length > 0 && (
<ProjectsTable projects={section.projects} />
)}
{/* Summary */}
{section.summary && <SummaryBlock summary={section.summary} />}
</section>
);
}
// ─── Data tables ───────────────────────────────────────
function DataTablesSection({
data,
}: {
data: Record<string, Array<{ period: string; value: number; unit: string }>>;
}) {
const entries = Object.entries(data).filter(
([, arr]) => Array.isArray(arr) && arr.length > 0,
);
if (entries.length === 0) return null;
return (
<div className="mt-4 space-y-4">
{entries.map(([key, items]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div key={key} className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium" colSpan={3}>
{label}
</th>
</tr>
<tr className="border-b">
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Kỳ
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Giá trị
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Đơn vị
</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} className="border-b last:border-0 even:bg-muted/30">
<td className="px-3 py-1.5">{item.period}</td>
<td className="px-3 py-1.5">
{typeof item.value === 'number'
? item.value.toLocaleString('vi-VN')
: String(item.value)}
</td>
<td className="px-3 py-1.5 text-muted-foreground">
{item.unit}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})}
</div>
);
}
// ─── Projects table ────────────────────────────────────
function ProjectsTable({
projects,
}: {
projects: Array<Record<string, unknown>>;
}) {
return (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Dự án</th>
<th className="px-3 py-2 text-left font-medium">Danh mục</th>
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
<th className="px-3 py-2 text-right font-medium">Vốn đu (VND)</th>
</tr>
</thead>
<tbody>
{projects.map((p, i) => (
<tr key={i} className="border-b last:border-0 even:bg-muted/30">
<td className="px-3 py-1.5 font-medium">
{String(p['name'] ?? '')}
</td>
<td className="px-3 py-1.5">{String(p['category'] ?? '')}</td>
<td className="px-3 py-1.5">{String(p['status'] ?? '')}</td>
<td className="px-3 py-1.5 text-right">
{p['investmentVND']
? Number(p['investmentVND']).toLocaleString('vi-VN')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Summary block ─────────────────────────────────────
function SummaryBlock({ summary }: { summary: Record<string, unknown> }) {
return (
<div className="mt-4 rounded-lg bg-muted/50 p-4">
{Object.entries(summary).map(([key, val]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
if (typeof val === 'number') {
return (
<p key={key} className="text-sm">
<span className="font-medium">{label}:</span>{' '}
{val.toLocaleString('vi-VN')}
</p>
);
}
if (typeof val === 'object' && val !== null) {
return (
<div key={key} className="mt-2">
<p className="text-sm font-medium">{label}:</p>
<ul className="ml-4 mt-1 list-disc text-sm text-muted-foreground">
{Object.entries(val as Record<string, unknown>).map(([k, v]) => (
<li key={k}>
{k}: {String(v)}
</li>
))}
</ul>
</div>
);
}
return null;
})}
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { FileText, Plus, X } from 'lucide-react';
import * as React from 'react';
import { ReportCard } from '@/components/reports/report-card';
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/navigation';
import { useReportsList, useDeleteReport } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
const PAGE_SIZE = 12;
export default function BaoCaoPage() {
const [typeFilter, setTypeFilter] = React.useState<ReportType | undefined>();
const [page, setPage] = React.useState(1);
const offset = (page - 1) * PAGE_SIZE;
const { data, isLoading, isError } = useReportsList({
type: typeFilter,
limit: PAGE_SIZE,
offset,
});
const deleteReport = useDeleteReport();
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0;
const handleTypeChange = (type: ReportType | undefined) => {
setTypeFilter(type);
setPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleDelete = (id: string) => {
deleteReport.mutate(id);
};
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Page header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold md:text-3xl">Báo cáo</h1>
<p className="mt-1 text-muted-foreground">
Quản tạo báo cáo phân tích bất đng sản
</p>
</div>
<Link href="/bao-cao/tao-moi">
<Button className="gap-2">
<Plus className="h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
{/* Type filter tabs */}
<div className="flex gap-1 overflow-x-auto border-b" role="tablist">
<button
role="tab"
aria-selected={!typeFilter}
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
!typeFilter
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleTypeChange(undefined)}
>
Tất cả
</button>
{REPORT_TYPES.map(({ value, label }) => (
<button
key={value}
role="tab"
aria-selected={typeFilter === value}
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
typeFilter === value
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => handleTypeChange(value)}
>
{label}
</button>
))}
{typeFilter && (
<button
className="ml-auto shrink-0 px-2 py-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => handleTypeChange(undefined)}
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Results */}
<div className="mt-6">
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Không thể tải danh sách báo cáo. Vui lòng thử lại.
</p>
<Button variant="outline" className="mt-4" onClick={() => setPage(page)}>
Thử lại
</Button>
</div>
) : data && data.data.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
{data.total} báo cáo
</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((report) => (
<ReportCard key={report.id} report={report} onDelete={handleDelete} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => handlePageChange(page - 1)}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Sau
</Button>
</div>
)}
</>
) : (
<div className="py-12 text-center">
<FileText className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Chưa báo cáo nào</p>
<p className="mt-1 text-sm text-muted-foreground">
Tạo báo cáo phân tích đu tiên của bạn
</p>
<Link href="/bao-cao/tao-moi">
<Button className="mt-4 gap-2">
<Plus className="h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
)}
</div>
</div>
);
}

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

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { TransferWizardClient } from '@/components/chuyen-nhuong/transfer-wizard-client';
export const metadata: Metadata = {
title: 'Đăng tin chuyển nhượng',
description: 'Đăng tin chuyển nhượng nội thất, thiết bị hoặc mặt bằng',
};
export default function DangTinPage() {
return <TransferWizardClient />;
}

View File

@@ -0,0 +1,136 @@
/* eslint-disable import-x/order */
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { ListingDetail } from '@/lib/listings-api';
// Mock the server-side listing fetch
vi.mock('@/lib/listings-server', () => ({
fetchListingById: vi.fn(),
}));
// Avoid pulling in the heavy client component during unit tests
vi.mock('@/components/listings/listing-detail-client', () => ({
ListingDetailClient: () => null,
}));
vi.mock('@/components/seo/json-ld', () => ({
JsonLd: () => null,
generateBreadcrumbJsonLd: () => ({}),
generateListingJsonLd: () => ({}),
}));
import { fetchListingById } from '@/lib/listings-server';
import { generateMetadata } from '../page';
const mockedFetch = vi.mocked(fetchListingById);
function buildListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
return {
id: 'listing-1',
status: 'APPROVED',
transactionType: 'SALE',
priceVND: '3500000000',
pricePerM2: null,
rentPriceMonthly: null,
commissionPct: null,
viewCount: 0,
saveCount: 0,
inquiryCount: 0,
publishedAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
property: {
id: 'prop-1',
propertyType: 'APARTMENT',
title: 'Căn hộ cao cấp Quận 1',
description: 'Đẹp, thoáng',
address: '123 Lê Lợi',
ward: 'Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
floors: 1,
direction: null,
yearBuilt: null,
legalStatus: null,
amenities: null,
projectName: null,
latitude: null,
longitude: null,
media: [
{
id: 'img-1',
url: 'https://cdn.example.com/img1.jpg',
type: 'image',
order: 0,
caption: null,
},
],
},
seller: { id: 'u-1', fullName: 'Nguyen Van A', phone: '0900000000' },
agent: null,
...overrides,
};
}
describe('listing page generateMetadata', () => {
beforeEach(() => {
mockedFetch.mockReset();
});
it('returns a not-found title when the listing is missing', async () => {
mockedFetch.mockResolvedValueOnce(null);
const meta = await generateMetadata({
params: Promise.resolve({ locale: 'vi', id: 'missing' }),
});
expect(meta.title).toMatch(/Không tìm thấy/);
});
it('builds OG + Twitter tags with image, canonical and alternates', async () => {
mockedFetch.mockResolvedValueOnce(buildListing());
const meta = await generateMetadata({
params: Promise.resolve({ locale: 'vi', id: 'listing-1' }),
});
expect(meta.title).toContain('Căn hộ cao cấp Quận 1');
expect(String(meta.description)).toContain('75 m');
expect(String(meta.description)).toContain('2 PN');
expect(String(meta.description)).toContain('Quận 1');
expect(meta.alternates?.canonical).toMatch(/\/vi\/listings\/listing-1$/);
expect(meta.alternates?.languages?.vi).toMatch(/\/vi\/listings\/listing-1$/);
expect(meta.alternates?.languages?.en).toMatch(/\/en\/listings\/listing-1$/);
const og = meta.openGraph as Record<string, unknown>;
expect(og.type).toBe('article');
expect(og.locale).toBe('vi_VN');
expect(og.siteName).toBe('GoodGo');
const ogImages = og.images as Array<{ url: string; width: number; height: number }>;
expect(ogImages[0]?.url).toBe('https://cdn.example.com/img1.jpg');
expect(ogImages[0]?.width).toBe(1200);
expect(ogImages[0]?.height).toBe(630);
const twitter = meta.twitter as Record<string, unknown>;
expect(twitter.card).toBe('summary_large_image');
expect((twitter.images as string[])[0]).toBe('https://cdn.example.com/img1.jpg');
expect(meta.other?.['og:price:currency']).toBe('VND');
expect(meta.other?.['og:price:amount']).toBe('3500000000');
});
it('falls back to default OG image when no media is present', async () => {
mockedFetch.mockResolvedValueOnce(
buildListing({
property: { ...buildListing().property, media: [] },
}),
);
const meta = await generateMetadata({
params: Promise.resolve({ locale: 'en', id: 'listing-1' }),
});
const og = meta.openGraph as Record<string, unknown>;
expect(og.locale).toBe('en_US');
const ogImages = og.images as Array<{ url: string }>;
expect(ogImages[0]?.url).toBe('/og-image.png');
});
});