347 lines
11 KiB
TypeScript
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 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>
|
|
);
|
|
}
|