feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
346
apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx
Normal file
346
apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx
Normal 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 và tạo báo cáo. Quá trình này
|
||||
có 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 tư (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>
|
||||
);
|
||||
}
|
||||
173
apps/web/app/[locale]/(public)/bao-cao/page.tsx
Normal file
173
apps/web/app/[locale]/(public)/bao-cao/page.tsx
Normal 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 lý và 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 có 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user