sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
@@ -0,0 +1,493 @@
/**
* Applicant-facing documents workspace: blank slate, history (`GET /api/applications/mine`),
* and review at `/dashboard/documents/review`. Admin/editor users with `applicationId` are
* redirected to the staff/council review routes so local submission storage is not overwritten.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { useAuth, apiClient, Button, Card, Badge } from "@ump/shared";
import { resolveApplicationDraftCaseId } from "@/lib/resolveApplicationDraftCaseId";
import type { ApplicationItem } from "@/data/mockApplications";
import { ReviewFormsPanel } from "@/components/application-review/ReviewFormsPanel";
import { axiosSuccessStatusOnly, getNestedData, normalizeApplicationItem } from "@/lib/applicationReviewApi";
import { useApplicationReviewDetail } from "@/hooks/useApplicationReviewDetail";
import { ApplicantHistoryPanel } from "@/components/applicant/history/ApplicantHistoryPanel";
import { ApplicantHistoryCrudDialog } from "@/components/applicant/history/ApplicantHistoryCrudDialog";
import {
loadApplicantSubmissionRecord,
syncApplicantSubmissionFromApplicationItem,
} from "@/lib/applicantSubmissionRecord";
import {
FRESH_DASHBOARD_SEARCH_PARAM,
getResumeApplicantDashboardHref,
prepareFreshApplicantDashboardNavigation,
} from "@ump/shared";
import { createApplicantApplication, updateApplicantApplication } from "@/lib/applicantApplicationsApi";
import { toast } from "sonner";
import {
FORMS_VIEW_SEARCH_PARAM,
COUNCIL_APPLICATION_REVIEW_PATH,
STAFF_APPLICATION_REVIEW_PATH,
buildApplicationReviewHref,
} from "@/lib/applicationReviewNavigation";
export default function ApplicationReviewPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const applicationId = searchParams.get("applicationId") ?? "";
const returnToRaw = searchParams.get("returnTo") ?? "/dashboard";
const returnTo = returnToRaw.startsWith("/dashboard") ? returnToRaw : "/dashboard";
const pathNorm = location.pathname.replace(/\/$/, "");
const isDocumentsListRoute = pathNorm === "/dashboard/documents";
const formsViewOnly = searchParams.get(FORMS_VIEW_SEARCH_PARAM) === "1";
/** Empty forms + cleared session; set by `?fresh=1` (same flag as dashboard new registration). */
const [formsBlankSlate, setFormsBlankSlate] = useState(
() => searchParams.get(FRESH_DASHBOARD_SEARCH_PARAM) === "1",
);
const { hasPermission, hasRole, user, isAuthenticated, loading: authLoading } = useAuth();
const isStaffReviewer = hasRole("admin") || hasRole("editor");
const staffApplicationReviewPath = hasRole("editor") ? COUNCIL_APPLICATION_REVIEW_PATH : STAFF_APPLICATION_REVIEW_PATH;
const canViewEvaluation = hasPermission("evaluation.view");
const canViewChat = hasPermission("chat.view");
const canInteractChat = hasPermission("chat.interact");
const canVerify = hasPermission("application.verify");
const canEditApplication = hasPermission("application.edit");
const submittedFormsReadOnly = !canEditApplication;
const reviewFormsReadOnly = submittedFormsReadOnly || formsViewOnly;
/** frontend_user always talks to the real backend (no demo mode). */
const useApplicantMine = isAuthenticated;
const [applicationDialog, setApplicationDialog] = useState<{
mode: "create" | "edit" | null;
item: ApplicationItem | null;
}>({ mode: null, item: null });
const [applicationDialogSubmitting, setApplicationDialogSubmitting] = useState(false);
const openApplicationCreateDialog = useCallback(() => {
setApplicationDialog({ mode: "create", item: null });
}, []);
const openApplicationMetadataDialog = useCallback((item: ApplicationItem) => {
setApplicationDialog({ mode: "edit", item });
}, []);
useEffect(() => {
if (searchParams.get(FRESH_DASHBOARD_SEARCH_PARAM) !== "1") return;
prepareFreshApplicantDashboardNavigation();
const next = new URLSearchParams(searchParams);
next.delete(FRESH_DASHBOARD_SEARCH_PARAM);
next.delete("applicationId");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const mineQuery = useQuery({
queryKey: ["applications-mine", user?.id],
enabled: useApplicantMine,
queryFn: async (): Promise<ApplicationItem[]> => {
const raw = await apiClient.get("/api/applications/mine");
const nested = getNestedData<ApplicationItem[] | undefined>(raw);
const list = Array.isArray(nested)
? nested
: raw && typeof raw === "object" && "data" in raw && Array.isArray((raw as { data: unknown }).data)
? (raw as { data: ApplicationItem[] }).data
: [];
return list.map((row) => normalizeApplicationItem(row as ApplicationItem & { draft_case_id?: string }));
},
staleTime: 30 * 1000,
});
const mineList = mineQuery.data ?? [];
/** Only show year/app switcher when this page is the applicants own submission (not admin reviewing another user). */
const showApplicantMineSwitcher =
useApplicantMine &&
(!applicationId || mineList.some((a) => a.id === applicationId));
const [verificationRequest, setVerificationRequest] = useState<{ fieldName: string; content: string } | null>(null);
const handleVerify = useCallback(
(fieldName: string, content: string) => {
if (canVerify) setVerificationRequest({ fieldName, content });
},
[canVerify],
);
const handleVerificationHandled = useCallback(() => setVerificationRequest(null), []);
const navigateToViewApplication = useCallback(
(item: ApplicationItem) => {
setFormsBlankSlate(false);
void queryClient.invalidateQueries({ queryKey: ["application-detail", item.id] });
void queryClient.invalidateQueries({ queryKey: ["application-draft-for-documents"] });
navigate(buildApplicationReviewHref({ applicationId: item.id, returnTo, viewOnly: true }));
},
[queryClient, navigate, returnTo],
);
const navigateToEditApplicationForms = useCallback(
async (item: ApplicationItem) => {
let normalized = normalizeApplicationItem(item as ApplicationItem & { draft_case_id?: string });
if (!normalized.draftCaseId) {
try {
const raw = await apiClient.get(
`/api/applications/${encodeURIComponent(item.id)}`,
axiosSuccessStatusOnly,
);
const data = getNestedData<ApplicationItem | null>(raw);
if (data && typeof data === "object" && "id" in data) {
normalized = normalizeApplicationItem(data as ApplicationItem & { draft_case_id?: string });
}
} catch {
/* list row + resolveApplicationDraftCaseId fallbacks */
}
}
const caseId = resolveApplicationDraftCaseId(normalized);
if (!caseId) {
toast.error("Không xác định được mã bản nháp (case) cho hồ sơ này.");
return;
}
syncApplicantSubmissionFromApplicationItem(normalized);
navigate(getResumeApplicantDashboardHref(caseId));
},
[navigate],
);
const { detailQuery, draftBundleQuery, detail, draftCaseIdForDetail } = useApplicationReviewDetail(applicationId, {
enabled: Boolean(applicationId) && !isDocumentsListRoute,
syncLocalSubmission: !isStaffReviewer,
});
const deleteMutation = useMutation({
mutationFn: async (applicationId: string) => {
await apiClient.delete(`/api/applications/${applicationId}`);
},
onSuccess: async (_, deletedId) => {
toast.success("Đã xóa hồ sơ.");
await mineQuery.refetch();
if (applicationId === deletedId) {
if (pathNorm.endsWith("/documents/review")) {
navigate("/dashboard/documents", { replace: true });
} else {
const sp = new URLSearchParams(searchParams);
sp.delete("applicationId");
setSearchParams(sp);
}
}
},
onError: (error: unknown) => {
toast.error(error instanceof Error ? error.message : "Không thể xóa hồ sơ.");
},
});
const handleApplicationDialogSubmit = useCallback(
async (payload: { name: string; submittedDate: string }) => {
const { mode, item } = applicationDialog;
if (mode === "create") {
setApplicationDialogSubmitting(true);
try {
const res = await createApplicantApplication(payload.name);
const row = res.application;
if (!row || typeof row !== "object") {
throw new Error("Máy chủ không trả dữ liệu hồ sơ mới.");
}
const normalized = normalizeApplicationItem(row as ApplicationItem & { draft_case_id?: string });
syncApplicantSubmissionFromApplicationItem(normalized);
const caseId = resolveApplicationDraftCaseId(normalized);
if (!caseId) {
throw new Error("Máy chủ không trả mã bản nháp (case) cho biểu mẫu.");
}
await mineQuery.refetch();
setApplicationDialog({ mode: null, item: null });
toast.success("Đã tạo hồ sơ. Chuyển tới bước điền biểu mẫu…");
navigate(getResumeApplicantDashboardHref(caseId));
} catch (e) {
toast.error(e instanceof Error ? e.message : "Không tạo được hồ sơ.");
} finally {
setApplicationDialogSubmitting(false);
}
return;
}
if (mode === "edit" && item) {
setApplicationDialogSubmitting(true);
try {
const raw = await updateApplicantApplication(item.id, payload);
const updated = normalizeApplicationItem(raw as ApplicationItem & { draft_case_id?: string });
syncApplicantSubmissionFromApplicationItem(updated);
await mineQuery.refetch();
await queryClient.invalidateQueries({ queryKey: ["application-detail", item.id] });
setApplicationDialog({ mode: null, item: null });
toast.success("Đã cập nhật thông tin hồ sơ.");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Không cập nhật được hồ sơ.");
} finally {
setApplicationDialogSubmitting(false);
}
}
},
[applicationDialog, mineQuery, navigate, queryClient],
);
const applicantCrudHandlers = useMemo(
() => ({
onViewApplication: navigateToViewApplication,
onEditApplicationForms: canEditApplication ? navigateToEditApplicationForms : undefined,
onEditApplicationMetadata: canEditApplication ? openApplicationMetadataDialog : undefined,
onDeleteApplication: async (id: string) => {
await deleteMutation.mutateAsync(id);
},
}),
[
navigateToViewApplication,
navigateToEditApplicationForms,
openApplicationMetadataDialog,
canEditApplication,
deleteMutation,
],
);
const applicantPrefill = useMemo(
() =>
detail
? {
initiativeName: detail.name,
authorName: detail.author.name,
}
: undefined,
[detail],
);
const mainPanelSize = canViewChat ? 70 : 100;
const chatPanelSize = canViewChat ? 30 : 0;
if (authLoading) {
return <div className="text-sm text-muted-foreground">Đang xác thực</div>;
}
if (isDocumentsListRoute && applicationId) {
if (isStaffReviewer) {
return <Navigate to={`${staffApplicationReviewPath}?${searchParams.toString()}`} replace />;
}
return <Navigate to={`/dashboard/documents/review?${searchParams.toString()}`} replace />;
}
if (isStaffReviewer && applicationId && pathNorm.endsWith("/documents/review")) {
return <Navigate to={`${staffApplicationReviewPath}?${searchParams.toString()}`} replace />;
}
const documentsFreshHref = `/dashboard/documents?${FRESH_DASHBOARD_SEARCH_PARAM}=1&returnTo=${encodeURIComponent(returnTo)}`;
if (!applicationId && formsBlankSlate) {
if (useApplicantMine && mineQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Đang tải</div>;
}
return (
<div className="h-[calc(100vh-4rem)] overflow-hidden animate-fade-in">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<Button variant="ghost" size="sm" asChild className="gap-2 -ml-2">
<Link to={returnTo}>
<ArrowLeft className="h-4 w-4" />
Về Dashboard
</Link>
</Button>
<h1 className="text-xl font-semibold tracking-tight">Biểu mẫu trống</h1>
<p className="text-sm text-muted-foreground max-w-3xl">
Đã xóa dữ liệu nháp trên trình duyệt ( hồ , bước báo cáo, lối tắt hồ vừa nộp). Các ô nhập dùng giá trị mặc đnh rỗng bạn thể điền lại từ đu hoặc chọn một hồ đã nộp bên dưới.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link to={documentsFreshHref} reloadDocument>
Làm trống lại
</Link>
</Button>
<Button size="sm" onClick={openApplicationCreateDialog}>
Đăng mới
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={documentsFreshHref} reloadDocument>
Biểu mẫu trống (làm mới)
</Link>
</Button>
</div>
</div>
{showApplicantMineSwitcher ? (
<ApplicantHistoryPanel
items={mineList}
loading={mineQuery.isLoading}
error={mineQuery.isError}
mutating={deleteMutation.isPending}
selectedApplicationId=""
onViewApplication={applicantCrudHandlers.onViewApplication}
onCreateApplication={openApplicationCreateDialog}
onEditApplicationForms={applicantCrudHandlers.onEditApplicationForms}
onEditApplicationMetadata={applicantCrudHandlers.onEditApplicationMetadata}
onDeleteApplication={applicantCrudHandlers.onDeleteApplication}
/>
) : null}
<ApplicantHistoryCrudDialog
open={applicationDialog.mode !== null}
mode={applicationDialog.mode === "edit" ? "edit" : "create"}
initialItem={applicationDialog.item ?? undefined}
submitting={applicationDialogSubmitting}
onOpenChange={(open) => {
if (!open) setApplicationDialog({ mode: null, item: null });
}}
onSubmit={handleApplicationDialogSubmit}
/>
<ReviewFormsPanel
caseKey="blank-slate"
applicantPrefill={undefined}
formsReadOnly={false}
canViewEvaluation={canViewEvaluation}
canViewChat={canViewChat}
canInteractChat={canInteractChat}
canVerify={canVerify}
mainPanelSize={mainPanelSize}
chatPanelSize={chatPanelSize}
verificationRequest={verificationRequest}
onVerify={handleVerify}
onVerificationHandled={handleVerificationHandled}
/>
</div>
);
}
if (!applicationId) {
if (useApplicantMine && mineQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Đang tải hồ từ máy chủ</div>;
}
if (useApplicantMine && mineQuery.isError) {
return (
<div className="mx-auto max-w-lg space-y-4">
<p className="text-destructive text-sm">
Không tải đưc danh sách hồ . Đăng nhập lại hoặc thử sau.
</p>
</div>
);
}
const last = loadApplicantSubmissionRecord();
const reviewLink =
last &&
buildApplicationReviewHref({ applicationId: last.applicationId, returnTo: "/dashboard" });
return (
<div className={useApplicantMine ? "space-y-4" : "mx-auto max-w-lg space-y-4"}>
{showApplicantMineSwitcher ? (
<ApplicantHistoryPanel
items={mineList}
loading={mineQuery.isLoading}
error={mineQuery.isError}
mutating={deleteMutation.isPending}
selectedApplicationId=""
onViewApplication={applicantCrudHandlers.onViewApplication}
onCreateApplication={openApplicationCreateDialog}
onEditApplicationForms={applicantCrudHandlers.onEditApplicationForms}
onEditApplicationMetadata={applicantCrudHandlers.onEditApplicationMetadata}
onDeleteApplication={applicantCrudHandlers.onDeleteApplication}
/>
) : null}
<ApplicantHistoryCrudDialog
open={applicationDialog.mode !== null}
mode={applicationDialog.mode === "edit" ? "edit" : "create"}
initialItem={applicationDialog.item ?? undefined}
submitting={applicationDialogSubmitting}
onOpenChange={(open) => {
if (!open) setApplicationDialog({ mode: null, item: null });
}}
onSubmit={handleApplicationDialogSubmit}
/>
<Card>
</Card>
</div>
);
}
if (detailQuery.isLoading) {
return (
<div className="text-sm text-muted-foreground">
Đang tải hồ
</div>
);
}
if (!detail) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild className="gap-2">
<Link to={returnTo}>
<ArrowLeft className="h-4 w-4" />
Về Dashboard
</Link>
</Button>
<p className="text-muted-foreground">Không tìm thấy hồ với đã chọn.</p>
</div>
);
}
const reviewStatusLabel = detail.reviewStatus ?? "—";
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden animate-fade-in">
<div className="mb-4 flex shrink-0 flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<Button variant="ghost" size="sm" asChild className="gap-2 -ml-2">
<Link to={`/dashboard/documents?returnTo=${encodeURIComponent(returnTo)}`} reloadDocument>
<ArrowLeft className="h-4 w-4" />
Quay lại danh sách
</Link>
</Button>
<h1 className="text-xl font-semibold tracking-tight">Hồ người nộp đơn</h1>
<p className="text-sm text-muted-foreground max-w-3xl">
Xem các biểu mẫu đã gửi: Báo cáo tả, Đơn đ nghị, Xác nhận đóng góp
{canViewEvaluation ? ", Phiếu đánh giá (Hội đồng)" : ""}.
</p>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<Badge variant="outline">
hồ : {detail.id}
</Badge>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>Đánh giá:</span>
<Badge variant="secondary">{reviewStatusLabel}</Badge>
</div>
</div>
</div>
<div className="min-h-0 flex-1">
{draftBundleQuery.isError ? (
<p className="text-sm text-amber-700 dark:text-amber-500 py-2">
Không tải đưc bản nháp đy đ (chỉ hiển thị tên hồ từ danh sách). Bạn thể chỉnh trên Dashboard nếu cần.
</p>
) : null}
<ReviewFormsPanel
caseKey={draftCaseIdForDetail || applicationId}
applicantPrefill={applicantPrefill}
draftTabs={draftBundleQuery.data?.tabs}
formsReadOnly={reviewFormsReadOnly}
canViewEvaluation={canViewEvaluation}
canViewChat={canViewChat}
canInteractChat={canInteractChat}
canVerify={canVerify}
mainPanelSize={mainPanelSize}
chatPanelSize={chatPanelSize}
verificationRequest={verificationRequest}
onVerify={handleVerify}
onVerificationHandled={handleVerificationHandled}
/>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { Construction } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@ump/shared";
/**
* Placeholder for applicant sidebar targets whose feature pages have not been
* migrated into frontend_user yet (initiative draft, profile, notifications, …).
* Styled with the shared design tokens so the shell looks complete and nothing 404s.
*/
export function ComingSoonPage() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Construction className="h-6 w-6 text-muted-foreground" />
</div>
<CardTitle className="font-serif">Tính năng đang phát triển</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Chức năng này sẽ sớm đưc hoàn thiện. Vui lòng quay lại sau.
</p>
</CardContent>
</Card>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ApplicantRegistrationDashboard } from "@/components/applicant/dashboard/ApplicantRegistrationDashboard";
/**
* Applicant dashboard landing (the /dashboard index).
*
* Renders the initiative-registration workspace ported from fe0
* (`ApplicantRegistrationDashboard`): the Báo cáo → Đơn → Đóng góp → Xem lại tab
* shell with report-gate locking and Postgres-backed draft lifecycle. In this
* walking-skeleton slice the four tab forms + ReviewPanel are placeholders; the real
* forms (slice 3) and preview/export (slice 4) drop in at their fe0-mirror paths.
*/
export function DashboardPage() {
return <ApplicantRegistrationDashboard />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LoginRegisterCard } from '@ump/shared';
export function LoginPage() {
return <LoginRegisterCard registerPath="/register" registerLabel="Đăng ký" />;
}
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Download, FileText } from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Button,
Input,
Label,
Badge,
listTemplates,
renderTemplate,
arrayBufferToObjectUrl,
saveArrayBufferAs,
TEMPLATE_PDF_MIME,
detailFromApiError,
} from '@ump/shared';
import { toast } from 'sonner';
/**
* Applicant flow: pick an admin-managed template → fill a form generated from its fields →
* render a PDF on the server (docxtpl + LibreOffice) → preview + download.
*/
export default function TemplatesFillPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const templatesQuery = useQuery({ queryKey: ['templates'], queryFn: listTemplates });
const templates = templatesQuery.data ?? [];
const selected = useMemo(
() => templates.find((t) => t.id === selectedId) ?? null,
[templates, selectedId],
);
if (selected) {
return <FillTemplate template={selected} onBack={() => setSelectedId(null)} />;
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">Mẫu tài liệu</h1>
<p className="text-sm text-muted-foreground">
Chọn một mẫu, điền thông tin tải về tệp PDF hoàn chỉnh.
</p>
</div>
{templatesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Đang tải</p>
) : templatesQuery.isError ? (
<p className="text-sm text-destructive">Không tải đưc danh sách mẫu.</p>
) : templates.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
Hiện chưa mẫu nào. Vui lòng quay lại sau.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => (
<Card key={t.id} className="flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
{t.name}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<p className="line-clamp-3 text-sm text-muted-foreground">
{t.description || 'Không có mô tả.'}
</p>
<div className="mt-auto flex items-center justify-between">
<Badge variant="secondary">{t.fields.length} trường</Badge>
<Button size="sm" onClick={() => setSelectedId(t.id)}>
Điền mẫu
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
function FillTemplate({
template,
onBack,
}: {
template: { id: string; name: string; fields: { key: string; label: string; type: string }[] };
onBack: () => void;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [rendering, setRendering] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [pdfBuf, setPdfBuf] = useState<ArrayBuffer | null>(null);
// Revoke the object URL when it changes / on unmount.
useEffect(() => {
return () => {
if (pdfUrl) URL.revokeObjectURL(pdfUrl);
};
}, [pdfUrl]);
const handleRender = async (e: FormEvent) => {
e.preventDefault();
setRendering(true);
try {
const buf = await renderTemplate(template.id, values, 'pdf');
setPdfBuf(buf);
setPdfUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return arrayBufferToObjectUrl(buf, TEMPLATE_PDF_MIME);
});
} catch (err) {
toast.error(detailFromApiError(err, 'Không tạo được PDF.'));
} finally {
setRendering(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-1">
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-2 gap-2">
<ArrowLeft className="h-4 w-4" />
Chọn mẫu khác
</Button>
<h1 className="text-xl font-semibold tracking-tight">{template.name}</h1>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Điền thông tin</CardTitle>
</CardHeader>
<CardContent>
{template.fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
Mẫu này không trường nào đ điền bạn vẫn thể tạo PDF.
</p>
) : null}
<form onSubmit={handleRender} className="space-y-4">
{template.fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<Label htmlFor={`f-${f.key}`}>{f.label}</Label>
<Input
id={`f-${f.key}`}
value={values[f.key] ?? ''}
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
disabled={rendering}
/>
</div>
))}
<Button type="submit" disabled={rendering} className="w-full">
{rendering ? 'Đang tạo PDF…' : 'Tạo PDF'}
</Button>
</form>
</CardContent>
</Card>
<Card className="flex flex-col">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base">Xem trước PDF</CardTitle>
{pdfBuf ? (
<Button
size="sm"
variant="outline"
className="gap-2"
onClick={() => saveArrayBufferAs(pdfBuf, `${template.name}.pdf`, TEMPLATE_PDF_MIME)}
>
<Download className="h-4 w-4" />
Tải về
</Button>
) : null}
</CardHeader>
<CardContent className="flex-1">
{pdfUrl ? (
<iframe title="PDF preview" src={pdfUrl} className="h-[70vh] w-full rounded-md border" />
) : (
<div className="flex h-[70vh] items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
Điền thông tin rồi bấm « Tạo PDF » đ xem trước.
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}