feat(web): add khu-cong-nghiep, chuyen-nhuong, and reports pages

Add three new frontend page sections:
- Industrial parks (khu-cong-nghiep): listing, detail, filter bar
- Transfer listings (chuyen-nhuong): search, category tabs, detail
- AI reports dashboard: list, create, viewer with TOC

Includes components, API clients, hooks, server helpers, i18n keys,
navigation links in public and dashboard layouts, and lint fixes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:07:45 +07:00
parent 62a8842193
commit 7ce651fce5
30 changed files with 2874 additions and 1 deletions

View File

@@ -0,0 +1,195 @@
'use client';
import { ArrowLeft, Download, Loader2, Printer } 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';
interface ReportSection {
title: string;
content?: string;
data?: unknown;
charts?: Record<string, unknown>;
projects?: unknown[];
summary?: unknown;
}
export default function ReportViewerPage() {
const routeParams = useParams();
const reportId = routeParams['id'] as string;
const { data: report, isLoading, refetch } = useReport(reportId);
const shouldPoll = report?.status === 'GENERATING';
const { data: statusData } = useReportStatus(reportId, shouldPoll);
// Refresh full report when status transitions to READY
React.useEffect(() => {
if (statusData?.status === 'READY' && report?.status === 'GENERATING') {
refetch();
}
}, [statusData?.status, report?.status, refetch]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!report) {
return (
<div className="py-20 text-center">
<p className="text-muted-foreground">Không tìm thấy báo cáo.</p>
</div>
);
}
const sections = (report.content as { sections?: Record<string, ReportSection> })?.sections;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Link href="/dashboard/reports">
<Button variant="ghost" size="sm" className="mt-1">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<ReportTypeBadge type={report.type} />
<ReportStatusBadge status={report.status} />
</div>
<h1 className="mt-1 text-xl font-bold">{report.title}</h1>
<p className="text-xs text-muted-foreground">
Tạo lúc {new Date(report.createdAt).toLocaleString('vi-VN')}
</p>
</div>
</div>
{report.status === 'READY' && (
<div className="flex items-center gap-2">
{report.pdfUrl && (
<a href={report.pdfUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
PDF
</Button>
</a>
)}
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="mr-1 h-4 w-4" />
In
</Button>
</div>
)}
</div>
{/* Generating state */}
{report.status === 'GENERATING' && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
<Loader2 className="mb-4 h-10 w-10 animate-spin text-primary" />
<h3 className="text-lg font-medium">Đang tạo báo cáo...</h3>
<p className="mt-1 text-sm text-muted-foreground">
Quá trình này thể mất vài phút. Trang sẽ tự cập nhật khi hoàn thành.
</p>
</div>
)}
{/* Failed state */}
{report.status === 'FAILED' && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6">
<h3 className="font-medium text-destructive">Tạo báo cáo thất bại</h3>
{report.errorMsg && (
<p className="mt-1 text-sm text-destructive/80">{report.errorMsg}</p>
)}
</div>
)}
{/* Report content */}
{report.status === 'READY' && sections && (
<div className="grid gap-4 lg:grid-cols-[200px_1fr]">
{/* Sidebar TOC */}
<nav className="hidden lg:block">
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Mục lục</p>
<ul className="space-y-1">
{Object.entries(sections).map(([key, section]) => (
<li key={key}>
<a
href={`#section-${key}`}
className="block rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{section.title}
</a>
</li>
))}
</ul>
</nav>
{/* Content sections */}
<div className="space-y-4">
{Object.entries(sections).map(([key, section]) => (
<section
key={key}
id={`section-${key}`}
className="rounded-lg border p-6"
>
<h2 className="mb-3 text-lg font-semibold">{section.title}</h2>
{section.content && (
<div className="space-y-2 text-sm leading-relaxed text-muted-foreground whitespace-pre-line">
{String(section.content)}
</div>
)}
{section.charts && (
<ReportChartsGrid charts={section.charts as Record<string, Array<{ period: string; value: number; unit: string }>>} />
)}
{section.data != null && !section.charts && (
<ReportChartsGrid charts={section.data as Record<string, Array<{ period: string; value: number; unit: string }>>} />
)}
{section.projects && Array.isArray(section.projects) && section.projects.length > 0 && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="pb-2 pr-4">Tên</th>
<th className="pb-2 pr-4">Loại</th>
<th className="pb-2 pr-4">Trạng thái</th>
</tr>
</thead>
<tbody>
{section.projects.map((proj: unknown, i: number) => {
const p = proj as Record<string, unknown>;
return (
<tr key={i} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{String(p['name'] ?? '')}</td>
<td className="py-2 pr-4">{String(p['category'] ?? '')}</td>
<td className="py-2 pr-4">{String(p['status'] ?? '')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
))}
</div>
</div>
)}
{/* Raw JSON fallback for content without sections */}
{report.status === 'READY' && !sections && report.content && (
<pre className="max-h-[600px] overflow-auto rounded-lg border bg-muted p-4 text-xs">
{JSON.stringify(report.content, null, 2)}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import { ArrowLeft, ArrowRight, Loader2, Sparkles } 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 { Link } from '@/i18n/navigation';
import { useGenerateReport } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
type Step = 'type' | 'params' | 'confirm';
const PARAM_FIELDS: Record<ReportType, Array<{ key: string; label: string; placeholder: string; required: boolean }>> = {
RESIDENTIAL_MARKET: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
{ key: 'period', label: 'Kỳ báo cáo', placeholder: '2026-Q1', required: true },
],
INDUSTRIAL_MARKET: [
{ key: 'province', label: 'Tỉnh/TP', placeholder: 'Bình Dương', required: true },
],
DISTRICT_ANALYSIS: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
{ key: 'district', label: 'Quận/Huyện', placeholder: 'Quận 2', required: true },
],
INVESTMENT_FEASIBILITY: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: true },
{ key: 'propertyType', label: 'Loại BĐS', placeholder: 'APARTMENT', required: true },
],
INDUSTRIAL_LOCATION: [
{ key: 'province', label: 'Tỉnh', placeholder: 'Bình Dương', required: true },
],
PROPERTY_VALUATION: [
{ key: 'propertyId', label: 'ID Bất động sản', placeholder: 'clx...', required: true },
],
PORTFOLIO: [
{ key: 'city', label: 'Thành phố', placeholder: 'Hồ Chí Minh', required: false },
],
};
export default function NewReportPage() {
const router = useRouter();
const { mutateAsync: trigger, isPending: isMutating } = useGenerateReport();
const [step, setStep] = React.useState<Step>('type');
const [selectedType, setSelectedType] = React.useState<ReportType | null>(null);
const [title, setTitle] = React.useState('');
const [params, setParams] = React.useState<Record<string, string>>({});
const typeInfo = selectedType ? REPORT_TYPES.find((t) => t.value === selectedType) : null;
const paramFields = selectedType ? PARAM_FIELDS[selectedType] : [];
const handleSelectType = (type: ReportType) => {
setSelectedType(type);
setParams({});
const label = REPORT_TYPES.find((t) => t.value === type)?.label ?? type;
setTitle(`Báo cáo ${label}`);
setStep('params');
};
const handleSubmit = async () => {
if (!selectedType) return;
const result = await trigger({
type: selectedType,
title,
params,
});
if (result?.reportId) {
router.push(`/dashboard/reports/${result.reportId}`);
}
};
return (
<div className="mx-auto max-w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/dashboard/reports">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Tạo báo cáo mới</h1>
<p className="text-sm text-muted-foreground">
{step === 'type' && 'Bước 1: Chọn loại báo cáo'}
{step === 'params' && 'Bước 2: Nhập thông tin'}
{step === 'confirm' && 'Bước 3: Xác nhận'}
</p>
</div>
</div>
{/* Step 1: Select type */}
{step === 'type' && (
<div className="grid gap-3 sm:grid-cols-2">
{REPORT_TYPES.map((rt) => (
<button
key={rt.value}
type="button"
className="flex items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:border-primary hover:bg-primary/5"
onClick={() => handleSelectType(rt.value)}
>
<rt.icon className="h-8 w-8 text-primary" />
<div>
<p className="font-medium">{rt.label}</p>
<p className="text-xs text-muted-foreground">Báo cáo {rt.label.toLowerCase()}</p>
</div>
</button>
))}
</div>
)}
{/* Step 2: Parameters */}
{step === 'params' && selectedType && (
<div className="space-y-4 rounded-lg border p-6">
<div>
<label className="mb-1 block text-sm font-medium">Tiêu đ báo cáo</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{paramFields.map((field) => (
<div key={field.key}>
<label className="mb-1 block text-sm font-medium">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</label>
<input
type="text"
placeholder={field.placeholder}
value={params[field.key] ?? ''}
onChange={(e) => setParams({ ...params, [field.key]: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
))}
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={() => setStep('type')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
<Button onClick={() => setStep('confirm')}>
Tiếp theo
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 'confirm' && selectedType && (
<div className="space-y-4 rounded-lg border p-6">
<h3 className="font-semibold">Xác nhận tạo báo cáo</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Loại báo cáo:</span>
<span className="font-medium">{typeInfo?.label}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tiêu đ:</span>
<span className="font-medium">{title}</span>
</div>
{Object.entries(params)
.filter(([, v]) => v)
.map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-muted-foreground">{key}:</span>
<span className="font-medium">{value}</span>
</div>
))}
</div>
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={() => setStep('params')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
<Button onClick={handleSubmit} disabled={isMutating}>
{isMutating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Đang tạo...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Tạo báo cáo
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { FileText, Plus } 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 { useDeleteReport, useReportsList } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
export default function ReportsHubPage() {
const [activeType, setActiveType] = React.useState<ReportType | undefined>();
const { data, isLoading } = useReportsList({ type: activeType, limit: 20 });
const { mutate: triggerDelete } = useDeleteReport();
const handleDelete = (id: string) => {
triggerDelete(id);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Báo cáo AI</h1>
<p className="text-sm text-muted-foreground">
Tạo quản báo cáo phân tích thị trường bất đng sản
</p>
</div>
<Link href="/dashboard/reports/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
{/* Type filter tabs */}
<div className="flex flex-wrap gap-2">
<Button
variant={activeType === undefined ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveType(undefined)}
>
Tất cả
</Button>
{REPORT_TYPES.map((rt) => (
<Button
key={rt.value}
variant={activeType === rt.value ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveType(rt.value)}
>
<rt.icon className="mr-1 h-3.5 w-3.5" />
{rt.label}
</Button>
))}
</div>
{/* Reports list */}
{isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2">Đang tải...</span>
</div>
)}
{!isLoading && data && data.data.length > 0 && (
<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>
)}
{!isLoading && data && data.data.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<FileText className="mb-4 h-12 w-12 text-muted-foreground/50" />
<h3 className="text-lg font-medium">Chưa báo cáo nào</h3>
<p className="mt-1 text-sm text-muted-foreground">
Tạo báo cáo AI đu tiên đ phân tích thị trường bất đng sản.
</p>
<Link href="/dashboard/reports/new" className="mt-4">
<Button>
<Plus className="mr-2 h-4 w-4" />
Tạo báo cáo mới
</Button>
</Link>
</div>
)}
{data && data.total > 0 && (
<p className="text-xs text-muted-foreground">
Hiển thị {data.data.length} / {data.total} báo cáo
</p>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import {
Bookmark,
Bot,
CreditCard,
FileText,
Gem,
Home,
List,
@@ -68,6 +69,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
label: t('dashboard.analytics'),
items: [
{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 },
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
],
@@ -93,6 +95,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
];
const secondaryNav: NavItem[] = [
{ href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText },
{ href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark },
{ href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot },
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },