Files
goodgo-platform/apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx

347 lines
11 KiB
TypeScript

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