sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 applicant’s 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 (mã hồ sơ, bước báo cáo, lối tắt hồ sơ vừa nộp). Các ô nhập dùng giá trị mặc định rỗng — bạn có thể điền lại từ đầu hoặc chọn một hồ sơ đã 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 kí 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ồ sơ 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ồ sơ. Đă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ồ sơ…
|
||||
</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ồ sơ với mã đã 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ồ sơ 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 mô 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">
|
||||
Mã hồ sơ: {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ồ sơ từ danh sách). Bạn có 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 và 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 có 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 có trường nào để điền — bạn vẫn có 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user