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
+22
View File
@@ -0,0 +1,22 @@
# Dev image for the user SPA — built within the npm workspace (build context = repo root).
# In docker-compose the workspace dirs are bind-mounted and deps are reinstalled on start,
# so this image just needs Node + a warm install for the first run.
FROM node:22-alpine
WORKDIR /app
# Workspace manifests first (layer cache). All three are needed so the workspace
# install resolves; only the user app + shared source are baked below.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY frontend_user/package.json ./frontend_user/
COPY frontend_admin/package.json ./frontend_admin/
COPY frontend_investigator/package.json ./frontend_investigator/
COPY frontend_publisher/package.json ./frontend_publisher/
RUN npm install
# Source (overridden by bind mounts in compose; baked so `docker run` also works).
COPY shared ./shared
COPY frontend_user ./frontend_user
EXPOSE 5173
CMD ["sh", "-c", "npm install && npm run dev -w frontend_user -- --host 0.0.0.0 --port 5173"]
+35
View File
@@ -0,0 +1,35 @@
# Production image for the applicant SPA (frontend_user).
# Multi-stage: build the minified bundle in the npm workspace, then serve it with nginx.
# The runtime image holds ONLY static files — no Node, no source, no Vite dev server — so
# end users inspecting the app see a minified bundle, never the original TypeScript.
#
# Build context = repo ROOT (the npm workspace):
# docker build -f frontend_user/Dockerfile.prod -t frontend_user .
# ---- build stage ----
FROM node:22-alpine AS build
WORKDIR /app
# Workspace manifests first (layer cache); all three are needed for `npm ci` to resolve.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY frontend_user/package.json ./frontend_user/
COPY frontend_admin/package.json ./frontend_admin/
COPY frontend_investigator/package.json ./frontend_investigator/
COPY frontend_publisher/package.json ./frontend_publisher/
RUN npm ci
# Only the sources this app needs: the applicant app + the shared kernel (consumed as source).
COPY shared ./shared
COPY frontend_user ./frontend_user
# Vite production build → /app/frontend_user/dist (minified, sourcemap:false).
RUN npm run build -w frontend_user
# ---- runtime stage (static only) ----
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY frontend_user/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/frontend_user/dist /usr/share/nginx/html
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UMP Sáng kiến — Người nộp đơn</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
# Production SPA + API reverse proxy for the applicant app (frontend_user, port 8080).
# Serves the minified Vite build only — no dev server, no /src, no sourcemaps. TLS terminates
# on the host reverse proxy (Caddy/nginx); this listens HTTP inside the Docker network.
server {
listen 8080;
server_name _;
server_tokens off; # do not advertise the nginx version
root /usr/share/nginx/html;
index index.html;
# Defense-in-depth headers (inherited by locations that set no add_header of their own).
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# A Content-Security-Policy is recommended but must enumerate the app's real sources.
# The applicant PDF export currently pulls @react-pdf NotoSerif fonts from remote URLs —
# self-host those first, then enable a locked-down policy, e.g.:
# add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'" always;
client_max_body_size 50m;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# Same-origin API + submitted PDFs → backend (the browser never talks to be0 directly).
location /api/ {
proxy_pass http://be0:4402;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
}
location /submitted-initiatives/ {
proxy_pass http://be0:4402;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Content-addressed build assets (hashed filenames) — safe to cache hard.
# Only `expires` here so the server-level security headers are still inherited.
location /assets/ {
expires 1y;
try_files $uri =404;
}
# SPA history fallback — unknown routes return index.html, never a listing or source file.
location / {
try_files $uri $uri/ /index.html;
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend_user",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@ump/shared": "*",
"@tanstack/react-query": "^5.83.0",
"axios": "^1.13.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"sonner": "^1.7.4"
},
"devDependencies": {
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3",
"vite": "^5.4.19"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

+62
View File
@@ -0,0 +1,62 @@
import { type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth, Toaster, ForgotPasswordPage, ResetPasswordPage, RegistrationWithOtp } from '@ump/shared';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import ApplicationReviewPage from './pages/ApplicationReviewPage';
import TemplatesFillPage from './pages/TemplatesFillPage';
import { ComingSoonPage } from './pages/ComingSoonPage';
import { DashboardLayout } from './layouts/DashboardLayout';
const queryClient = new QueryClient();
function RequireAuth({ children }: { children: ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) return <div style={{ padding: 24 }}>Đang tải</div>;
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/register" element={<RegistrationWithOtp variant="applicant" loginPath="/login" />} />
{/* Authed applicant shell: sidebar + header wrap the routed content. */}
<Route
element={
<RequireAuth>
<DashboardLayout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<DashboardPage />} />
{/* ImageHub team datasets — deferred (team-membership model not built yet). */}
<Route path="/dashboard/datasets" element={<ComingSoonPage />} />
{/* Lịch sử đăng ký — applicant documents workspace (history list + read-only review). */}
<Route path="/dashboard/documents" element={<ApplicationReviewPage />} />
<Route path="/dashboard/documents/review" element={<ApplicationReviewPage />} />
<Route path="/dashboard/templates" element={<TemplatesFillPage />} />
{/* Sidebar targets whose feature pages are not migrated yet → ComingSoon. */}
<Route path="/dashboard/notifications" element={<ComingSoonPage />} />
<Route path="/dashboard/settings" element={<ComingSoonPage />} />
<Route path="/dashboard/help" element={<ComingSoonPage />} />
<Route path="/applicant/profile" element={<ComingSoonPage />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
<Toaster />
</AuthProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,936 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Plus, Trash2, Save, FileText, Pencil, Check, X, CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import {
Button,
Input,
Textarea,
Card,
CardContent,
CardTitle,
Checkbox,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableFooter,
Popover,
PopoverContent,
PopoverTrigger,
Calendar,
cn,
collectContributionDigitalSignaturePrerequisiteGaps,
formatApplicantPrerequisiteToastDescription,
type ApplicantPrefill,
useOptionalDraft,
canonicalMainAuthorFromDraft,
pushMainAuthorToDraft,
reconcileRepresentativeAuthorSlices,
type Author,
authorToContributionRow,
contributionColumnToAuthorField,
createEmptyAuthorRow,
normalizeContributionPercent,
} from '@ump/shared';
import { exportDraftPdf } from '@/components/applicant/initiative-draft/pdfExport';
interface Participant {
id: number;
fullName: string;
workUnit: string;
contributionPercent: number;
isEditing: boolean;
}
interface ContributionFormState {
initiativeName: string;
mainAuthor: string;
position: string;
representativePercent: number;
submissionDate: Date;
participants: Participant[];
digitalSignatureConfirmed: boolean;
}
interface ContributionConfirmationFormProps {
onVerify?: (fieldName: string, content: string) => void;
/** When true, all fields are non-interactive (e.g. staff review). */
readOnly?: boolean;
showVerifyButton?: boolean; // Only Admin can see verify button
applicantPrefill?: ApplicantPrefill;
initialDraft?: Partial<ContributionFormState>;
onSaveDraft?: (data: ContributionFormState) => Promise<void>;
autoSaveDebounceMs?: number;
/** After a successful save (e.g. Dashboard switches to « Xem lại & tải PDF »). */
onNext?: () => void;
/**
* When there is no DraftProvider (e.g. admin case review), pass the Đơn tab `authors` array
* so this table matches « Đơn Đề nghị Công nhận ». Ignored whenever a draft context exists.
*/
mirrorApplicationAuthors?: Author[];
/**
* When there is no DraftProvider, pass `application.initiativeName` from the Đơn tab JSON
* so this field matches tab « Đơn Đề nghị ». Omit in applicant flow. Ignored when a draft context exists.
*/
mirrorInitiativeNameFromApplication?: string;
}
const initialFormState: ContributionFormState = {
initiativeName: '',
mainAuthor: '',
position: '',
representativePercent: 0,
submissionDate: new Date(),
participants: [],
digitalSignatureConfirmed: false,
};
export default function ContributionConfirmationForm({
onVerify,
readOnly = false,
showVerifyButton = false,
applicantPrefill,
initialDraft,
onSaveDraft,
autoSaveDebounceMs,
onNext,
mirrorApplicationAuthors: mirrorApplicationAuthorsProp,
mirrorInitiativeNameFromApplication: mirrorInitiativeNameFromApplicationProp,
}: ContributionConfirmationFormProps) {
const draftCtx = useOptionalDraft();
const draftCtxRef = useRef(draftCtx);
draftCtxRef.current = draftCtx;
const draft = draftCtx?.draft;
const draftId = draft?.draftId;
const updateReport = draftCtx?.updateReport;
const hasDraftContext = draftCtx != null;
const [formData, setFormData] = useState<ContributionFormState>(initialFormState);
const autoSaveSkipRef = useRef(true);
const [editingRow, setEditingRow] = useState<Participant | null>(null);
/** Đơn §1 authors — edit row / cancel backup when table is driven by draft */
const [editingAuthorId, setEditingAuthorId] = useState<number | null>(null);
const [authorEditBackup, setAuthorEditBackup] = useState<Author | null>(null);
const [isSavingDraft, setIsSavingDraft] = useState(false);
useEffect(() => {
if (hasDraftContext) return;
if (!applicantPrefill) return;
setFormData((prev) => ({
...prev,
...(applicantPrefill.initiativeName ? { initiativeName: applicantPrefill.initiativeName } : {}),
...(applicantPrefill.authorName ? { mainAuthor: applicantPrefill.authorName } : {}),
}));
}, [hasDraftContext, applicantPrefill?.initiativeName, applicantPrefill?.authorName]);
useEffect(() => {
if (hasDraftContext) return;
if (!initialDraft) return;
setFormData((prev) => ({ ...prev, ...initialDraft }));
}, [hasDraftContext, initialDraft]);
useEffect(() => {
if (!onSaveDraft || !autoSaveDebounceMs || readOnly) return;
if (autoSaveSkipRef.current) {
autoSaveSkipRef.current = false;
return;
}
const id = window.setTimeout(() => {
void (async () => {
try {
await onSaveDraft(formData);
} catch (e) {
console.error("Đóng góp — tự động lưu bản nháp thất bại:", e);
}
})();
}, autoSaveDebounceMs);
return () => window.clearTimeout(id);
}, [formData, onSaveDraft, autoSaveDebounceMs, readOnly]);
const handleSaveDraft = async () => {
if (isSavingDraft) return;
setIsSavingDraft(true);
try {
if (onSaveDraft) {
await onSaveDraft(formData);
}
toast.success('Đã lưu bản nháp xác nhận đóng góp.');
onNext?.();
} finally {
setIsSavingDraft(false);
}
};
const handleExportCurrentPdf = async () => {
if (!draftCtx) {
toast.error('Không tìm thấy dữ liệu bản nháp để xuất PDF.');
return;
}
try {
await exportDraftPdf(draftCtx.draft, draftCtx.caseId);
toast.success('Đã xuất PDF hồ sơ hiện tại.');
} catch {
toast.error('Không thể xuất PDF hồ sơ hiện tại.');
}
};
const applicationAuthors = draft?.application.authors;
/** Same idea as `applicationAuthors` from DraftProvider: admin review passes Đơn `authors` explicitly. */
const mirrorsFromApplication =
!draftCtx &&
Array.isArray(mirrorApplicationAuthorsProp) &&
mirrorApplicationAuthorsProp.length > 0;
const mirrorApplicationAuthors = mirrorsFromApplication
? mirrorApplicationAuthorsProp
: undefined;
const mirrorsInitiativeNameFromApplication =
!draftCtx && mirrorInitiativeNameFromApplicationProp !== undefined;
/** Rows for § “Tỷ Lệ Đóng Góp”: mirror Đơn §1 `authors` when a draft exists (or when `mirrorApplicationAuthors` is set). */
const tableParticipants = useMemo((): Participant[] => {
if (hasDraftContext && applicationAuthors) {
if (applicationAuthors.length === 0) return [];
return applicationAuthors.map((a) =>
authorToContributionRow(a, editingAuthorId === a.id),
);
}
if (mirrorsFromApplication && mirrorApplicationAuthors) {
return mirrorApplicationAuthors.map((a) => authorToContributionRow(a, false));
}
return formData.participants;
}, [
hasDraftContext,
applicationAuthors,
formData.participants,
editingAuthorId,
mirrorsFromApplication,
mirrorApplicationAuthors,
]);
const totalPercent = useMemo(() => {
if (hasDraftContext && draft) {
return draft.application.authors.reduce(
(sum, a) => sum + Number(a.contributionPercent || 0),
0,
);
}
if (mirrorsFromApplication && mirrorApplicationAuthors) {
return mirrorApplicationAuthors.reduce(
(sum, a) => sum + Number(a.contributionPercent || 0),
0,
);
}
return formData.participants.reduce((sum, p) => sum + (p.contributionPercent || 0), 0);
}, [
hasDraftContext,
draft?.application.authors,
formData.participants,
mirrorsFromApplication,
mirrorApplicationAuthors,
]);
const isValid = totalPercent === 100;
const contributionSignaturePrerequisiteGaps = useMemo(() => {
if (!draftCtx || readOnly) return [];
return collectContributionDigitalSignaturePrerequisiteGaps(
draftCtx.draft.report,
draftCtx.draft.application,
);
}, [draftCtx, readOnly, draft?.report, draft?.application]);
/** Đồng bộ block tác giả chính với `authors[0]` khi bảng lấy từ tab Đơn (admin — không DraftProvider). */
useEffect(() => {
if (hasDraftContext) return;
if (!mirrorsFromApplication || !mirrorApplicationAuthors) return;
const a0 = mirrorApplicationAuthors[0];
if (!a0) return;
setFormData((prev) => {
const mainAuthor = a0.name ?? '';
const position = a0.workplace ?? '';
const representativePercent = Number(a0.contributionPercent) || 0;
if (
prev.mainAuthor === mainAuthor &&
prev.position === position &&
prev.representativePercent === representativePercent
) {
return prev;
}
return { ...prev, mainAuthor, position, representativePercent };
});
}, [hasDraftContext, mirrorsFromApplication, mirrorApplicationAuthors]);
/** Tên sáng kiến từ tab Đơn (admin — không DraftProvider). */
useEffect(() => {
if (hasDraftContext) return;
if (mirrorInitiativeNameFromApplicationProp === undefined) return;
setFormData((prev) => {
if (prev.initiativeName === mirrorInitiativeNameFromApplicationProp) return prev;
return { ...prev, initiativeName: mirrorInitiativeNameFromApplicationProp };
});
}, [hasDraftContext, mirrorInitiativeNameFromApplicationProp]);
// Sync main author as first participant (standalone / no draft only; skip when bảng lấy từ tab Đơn)
useEffect(() => {
if (hasDraftContext) return;
if (mirrorsFromApplication) return;
if (formData.mainAuthor && formData.participants.length === 0) {
const newParticipant: Participant = {
id: Date.now(),
fullName: formData.mainAuthor,
workUnit: formData.position,
contributionPercent: formData.representativePercent,
isEditing: false,
};
setFormData((prev) => ({
...prev,
participants: [newParticipant],
}));
} else if (formData.participants.length > 0) {
setFormData((prev) => ({
...prev,
participants: prev.participants.map((p, index) =>
index === 0
? {
...p,
fullName: formData.mainAuthor,
workUnit: formData.position,
contributionPercent: formData.representativePercent,
}
: p,
),
}));
}
}, [
hasDraftContext,
mirrorsFromApplication,
formData.mainAuthor,
formData.position,
formData.representativePercent,
formData.participants.length,
]);
/** Keep header “tác giả chính” block aligned with Đơn `authors[0]` when the đơn table changes elsewhere. */
useEffect(() => {
if (!draft || readOnly) return;
const a0 = draft.application.authors[0];
if (!a0) return;
setFormData((prev) => {
const mainAuthor = a0.name ?? '';
const position = a0.workplace ?? '';
const representativePercent = Number(a0.contributionPercent) || 0;
if (
prev.mainAuthor === mainAuthor &&
prev.position === position &&
prev.representativePercent === representativePercent
) {
return prev;
}
return { ...prev, mainAuthor, position, representativePercent };
});
}, [readOnly, draftId, applicationAuthors]);
/**
* “Tên sáng kiến” is the same field as báo cáo §2.1 and Đơn (`report.initiativeName` / `application.initiativeName`).
* @see InitiativeReportForm initiative name input
*/
const handleInitiativeNameChange = useCallback(
(value: string) => {
if (readOnly) return;
if (mirrorsInitiativeNameFromApplication) return;
setFormData((prev) => ({ ...prev, initiativeName: value }));
const ctx = draftCtxRef.current;
if (ctx && !readOnly) {
ctx.updateReport({ initiativeName: value });
void ctx.updateApplication({ initiativeName: value });
}
},
[readOnly, mirrorsInitiativeNameFromApplication],
);
useEffect(() => {
if (!draft || readOnly) return;
const appName = draft.application.initiativeName ?? '';
setFormData((prev) => {
if (prev.initiativeName === appName) return prev;
return { ...prev, initiativeName: appName };
});
const reportName = draft.report.initiativeName ?? '';
if (reportName !== appName && updateReport) {
updateReport({ initiativeName: appName });
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- use draft primitives + stable updateReport; omit unstable context
}, [readOnly, draftId, draft?.application.initiativeName, draft?.report.initiativeName, updateReport]);
/**
* Trùng với Mẫu 03 §2 và báo cáo §2.2 (`report.representativeAuthor`), Đơn tác giả thứ nhất.
* @see InitiativeReportForm §2.2
*/
const handleMainAuthorChange = useCallback(
(value: string) => {
if (readOnly) return;
if (mirrorsFromApplication) return;
setFormData((prev) => ({ ...prev, mainAuthor: value }));
const ctx = draftCtxRef.current;
if (ctx && !readOnly) {
pushMainAuthorToDraft(ctx, value);
}
},
[readOnly, mirrorsFromApplication],
);
useEffect(() => {
if (readOnly) return;
const ctx = draftCtxRef.current;
if (!ctx) return;
const canonical = canonicalMainAuthorFromDraft(ctx.draft);
setFormData((prev) => {
if (prev.mainAuthor === canonical) return prev;
return { ...prev, mainAuthor: canonical };
});
reconcileRepresentativeAuthorSlices(ctx, readOnly);
}, [
readOnly,
draftId,
draft?.application.authors[0]?.name,
draft?.report.representativeAuthor,
]);
const patchFirstAuthorInDraft = useCallback(
(patch: Partial<Pick<Author, 'name' | 'workplace' | 'contributionPercent'>>) => {
const ctx = draftCtxRef.current;
if (!ctx || readOnly) return;
const authors = ctx.draft.application.authors;
const first = authors[0];
if (!first) return;
void ctx.updateApplication({
authors: [{ ...first, ...patch }, ...authors.slice(1)],
});
},
[readOnly],
);
const handlePositionChange = useCallback(
(value: string) => {
if (readOnly) return;
if (mirrorsFromApplication) return;
setFormData((prev) => ({ ...prev, position: value }));
patchFirstAuthorInDraft({ workplace: value });
},
[patchFirstAuthorInDraft, mirrorsFromApplication],
);
const handleRepresentativePercentChange = useCallback(
(value: number) => {
if (readOnly) return;
if (mirrorsFromApplication) return;
setFormData((prev) => ({ ...prev, representativePercent: value }));
patchFirstAuthorInDraft({ contributionPercent: value });
},
[patchFirstAuthorInDraft, mirrorsFromApplication],
);
const handleInputChange = (field: keyof ContributionFormState, value: string | number | Date | boolean) => {
if (readOnly) return;
setFormData((prev) => ({ ...prev, [field]: value }));
};
const addParticipant = () => {
if (readOnly) return;
if (mirrorsFromApplication) {
toast.info('Thêm tác giả ở tab « Đơn Đề nghị Công nhận » (bảng đang đồng bộ với Đơn).');
return;
}
if (draftCtx) {
const newAuthor = createEmptyAuthorRow();
const authors = [...draftCtx.draft.application.authors, newAuthor];
void draftCtx.updateApplication({ authors });
setEditingAuthorId(newAuthor.id);
setAuthorEditBackup(null);
return;
}
const newParticipant: Participant = {
id: Date.now(),
fullName: '',
workUnit: '',
contributionPercent: 0,
isEditing: true,
};
setFormData((prev) => ({
...prev,
participants: [...prev.participants, newParticipant],
}));
setEditingRow(newParticipant);
};
const updateParticipant = (id: number, field: keyof Participant, value: string | number | boolean) => {
if (mirrorsFromApplication && !draftCtx) return;
if (draftCtx && !readOnly) {
const col =
field === 'fullName' || field === 'workUnit' || field === 'contributionPercent' ? field : null;
if (!col) return;
const authorKey = contributionColumnToAuthorField(col);
const authors = draftCtx.draft.application.authors.map((a) => {
if (a.id !== id) return a;
const v =
authorKey === 'contributionPercent'
? normalizeContributionPercent(value)
: value;
return { ...a, [authorKey]: v };
});
void draftCtx.updateApplication({ authors });
return;
}
setFormData((prev) => ({
...prev,
participants: prev.participants.map((p) => (p.id === id ? { ...p, [field]: value } : p)),
}));
};
const removeParticipant = (id: number) => {
if (mirrorsFromApplication) {
toast.info('Xóa hoặc chỉnh tác giả ở tab « Đơn Đề nghị Công nhận » (bảng đang đồng bộ với Đơn).');
return;
}
if (tableParticipants[0]?.id === id) {
toast.error('Không thể xóa tác giả chính');
return;
}
if (draftCtx && !readOnly) {
const authors = draftCtx.draft.application.authors.filter((a) => a.id !== id);
void draftCtx.updateApplication({ authors });
if (editingAuthorId === id) {
setEditingAuthorId(null);
setAuthorEditBackup(null);
}
return;
}
setFormData((prev) => ({
...prev,
participants: prev.participants.filter((p) => p.id !== id),
}));
};
const startEditing = (participant: Participant) => {
if (readOnly) return;
if (mirrorsFromApplication) {
toast.info('Chỉnh tác giả ở tab « Đơn Đề nghị Công nhận » (bảng đang đồng bộ với Đơn).');
return;
}
if (draftCtx) {
const author = draftCtx.draft.application.authors.find((a) => a.id === participant.id);
setAuthorEditBackup(author ? { ...author } : null);
setEditingAuthorId(participant.id);
return;
}
setEditingRow({ ...participant });
updateParticipant(participant.id, 'isEditing', true);
};
const cancelEditing = (id: number) => {
if (draftCtx && !readOnly) {
if (authorEditBackup?.id === id) {
void draftCtx.updateApplication({
authors: draftCtx.draft.application.authors.map((a) => (a.id === id ? authorEditBackup : a)),
});
}
if (editingAuthorId === id) {
setEditingAuthorId(null);
setAuthorEditBackup(null);
}
return;
}
if (editingRow) {
setFormData((prev) => ({
...prev,
participants: prev.participants.map((p) =>
p.id === id ? { ...editingRow, isEditing: false } : p,
),
}));
}
setEditingRow(null);
};
const saveEditing = (id: number) => {
if (readOnly) return;
if (draftCtx) {
setEditingAuthorId(null);
setAuthorEditBackup(null);
return;
}
updateParticipant(id, 'isEditing', false);
setEditingRow(null);
};
const handleSubmit = () => {
if (!isValid) {
toast.error('Tổng tỷ lệ đóng góp phải bằng 100%');
return;
}
if (!formData.digitalSignatureConfirmed) {
toast.error('Vui lòng xác nhận chữ ký số');
return;
}
if (!formData.initiativeName.trim()) {
toast.error('Vui lòng nhập tên sáng kiến');
return;
}
if (!formData.mainAuthor.trim()) {
toast.error('Vui lòng nhập tên tác giả chính');
return;
}
toast.success('Đã lưu bản xác nhận thành công!');
};
return (
<Card className="border-border shadow-lg">
<div className="relative">
<div className="text-center mt-6 space-y-1">
<div className="flex justify-center gap-8 text-xs text-muted-foreground mb-4">
<div>
<p className="font-semibold">BỘ Y TẾ</p>
<p className="font-bold text-foreground">ĐI HỌC Y DƯỢC</p>
<p className="font-bold text-foreground">THÀNH PHỐ HỒ CHÍ MINH</p>
</div>
<div>
<p className="font-semibold">CỘNG HÒA HỘI CHỦ NGHĨA VIỆT NAM</p>
<p className="font-bold text-foreground">Đc lập Tự do Hạnh phúc</p>
</div>
</div>
<CardTitle className="text-2xl font-bold uppercase">
Bản Xác Nhận
</CardTitle>
<p className="text-lg font-semibold text-foreground">
Tỷ Lệ (%) Đóng Góp Vào Việc Tạo Ra Sáng Kiến
</p>
</div>
</div>
<CardContent className="p-6 md:p-8 space-y-6">
{/* Section 1: General Information */}
<section className="space-y-4">
<h2 className="text-lg font-bold text-foreground border-b border-border pb-2">
Thông Tin Chung
</h2>
<div>
<Label htmlFor="initiativeName" className="text-sm font-medium">
1. Tên sáng kiến <span className="text-destructive">*</span>
</Label>
<Textarea
id="initiativeName"
placeholder="Nhập tên sáng kiến..."
className="min-h-20 mt-1"
value={formData.initiativeName}
onChange={(e) => handleInitiativeNameChange(e.target.value)}
disabled={readOnly || mirrorsInitiativeNameFromApplication}
/>
</div>
<div>
<Label htmlFor="mainAuthor" className="text-sm font-medium">
2. Tác giả chính / Đi diện nhóm tác giả sáng kiến <span className="text-destructive">*</span>
</Label>
<Input
id="mainAuthor"
placeholder="Nhập họ và tên..."
className="mt-1"
value={formData.mainAuthor}
onChange={(e) => handleMainAuthorChange(e.target.value)}
disabled={readOnly || mirrorsFromApplication}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="position" className="text-sm font-medium">
Chức vụ, đơn vị công tác
</Label>
<Input
id="position"
placeholder="Nhập chức vụ và đơn vị..."
className="mt-1"
value={formData.position}
onChange={(e) => handlePositionChange(e.target.value)}
disabled={readOnly || mirrorsFromApplication}
/>
</div>
<div>
<Label htmlFor="representativePercent" className="text-sm font-medium">
Tỷ lệ đóng góp của đi diện (%)
</Label>
<Input
id="representativePercent"
type="number"
min={0}
max={100}
placeholder="0"
className="mt-1"
value={formData.representativePercent || ''}
onChange={(e) => handleRepresentativePercentChange(Number(e.target.value))}
disabled={readOnly || mirrorsFromApplication}
/>
</div>
</div>
<div>
<Label className="text-sm font-medium">Ngày nộp</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={readOnly}
className={cn(
"w-full md:w-[280px] justify-start text-left font-normal mt-1",
!formData.submissionDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.submissionDate ? (
format(formData.submissionDate, 'dd/MM/yyyy')
) : (
<span>Chọn ngày</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.submissionDate}
onSelect={(date) => date && handleInputChange('submissionDate', date)}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
</div>
</section>
{/* Section 2: Participant Table */}
<section className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-bold text-foreground border-b border-border pb-2 flex-1">
Tỷ Lệ Đóng Góp
</h2>
<Button
type="button"
onClick={addParticipant}
size="sm"
className="gap-1"
disabled={readOnly || mirrorsFromApplication}
>
<Plus size={16} /> Thêm thành viên
</Button>
</div>
<div className="overflow-x-auto border border-border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16 text-center">STT</TableHead>
<TableHead>Họ tên</TableHead>
<TableHead>Đơn vị công tác</TableHead>
<TableHead className="w-32 text-center">% đóng góp</TableHead>
<TableHead className="w-24 text-center">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tableParticipants.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground italic py-8">
Nhập thông tin tác giả chính đ bắt đu
</TableCell>
</TableRow>
)}
{tableParticipants.map((participant, index) => (
<TableRow key={participant.id}>
<TableCell className="text-center font-medium">{index + 1}</TableCell>
<TableCell>
{participant.isEditing ? (
<Input
className="border-input"
value={participant.fullName}
onChange={(e) => updateParticipant(participant.id, 'fullName', e.target.value)}
disabled={index === 0 || readOnly || mirrorsFromApplication}
/>
) : (
<span>{participant.fullName || '-'}</span>
)}
</TableCell>
<TableCell>
{participant.isEditing ? (
<Input
className="border-input"
value={participant.workUnit}
onChange={(e) => updateParticipant(participant.id, 'workUnit', e.target.value)}
disabled={index === 0 || readOnly || mirrorsFromApplication}
/>
) : (
<span>{participant.workUnit || '-'}</span>
)}
</TableCell>
<TableCell className="text-center">
{participant.isEditing ? (
<Input
type="number"
min={0}
max={100}
className="border-input text-center w-20 mx-auto"
value={participant.contributionPercent || ''}
onChange={(e) => updateParticipant(participant.id, 'contributionPercent', Number(e.target.value))}
disabled={index === 0 || readOnly || mirrorsFromApplication}
/>
) : (
<span>{participant.contributionPercent}%</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-1">
{index === 0 ? (
<span className="text-xs text-muted-foreground italic">Tác giả chính</span>
) : readOnly || mirrorsFromApplication ? (
<span className="text-xs text-muted-foreground"></span>
) : participant.isEditing ? (
<>
<Button
variant="ghost"
size="icon"
onClick={() => saveEditing(participant.id)}
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
>
<Check size={16} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => cancelEditing(participant.id)}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<X size={16} />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={() => startEditing(participant)}
className="h-8 w-8 text-primary hover:text-primary hover:bg-primary/10"
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeParticipant(participant.id)}
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={16} />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3} className="text-right font-bold">
TỔNG CỘNG
</TableCell>
<TableCell className={cn(
"text-center font-bold text-lg",
isValid ? "text-green-600" : "text-destructive"
)}>
{totalPercent}%
</TableCell>
<TableCell className="text-center">
{isValid ? (
<span className="text-green-600 text-xs"> Hợp lệ</span>
) : (
<span className="text-destructive text-xs">Cần = 100%</span>
)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{!isValid && tableParticipants.length > 0 && (
<p className="text-sm text-destructive">
Tổng tỷ lệ đóng góp phải bằng đúng 100% đ thể gửi biểu mẫu.
</p>
)}
</section>
{/* Section 3: Signature */}
<section className="border-t border-border pt-6 space-y-6">
<div className="flex items-center space-x-3">
<Checkbox
id="digitalSignature"
checked={formData.digitalSignatureConfirmed}
disabled={readOnly}
onCheckedChange={(checked) => {
if (readOnly) return;
const v = !!checked;
if (v && draftCtx && contributionSignaturePrerequisiteGaps.length > 0) {
toast.error('Chưa thể xác nhận cam kết', {
description: formatApplicantPrerequisiteToastDescription(
contributionSignaturePrerequisiteGaps,
),
});
return;
}
handleInputChange('digitalSignatureConfirmed', v);
}}
/>
<Label htmlFor="digitalSignature" className="text-sm font-medium cursor-pointer">
Tôi xin cam đoan mọi thông tin nêu trong đơn trung thực, đúng sự thật hoàn toàn chịu
trách nhiệm trước pháp luật.
</Label>
</div>
<div className="flex flex-col md:flex-row justify-between items-start gap-8">
<div className="text-center w-full md:w-1/2">
<p className="italic text-muted-foreground mb-2">
TP. Hồ Chí Minh, {format(formData.submissionDate, 'dd/MM/yyyy')}
</p>
<p className="font-bold uppercase mb-16 text-foreground">
Tác giả chính / Đi diện nhóm tác giả sáng kiến
</p>
<div className="border-t border-border w-48 mx-auto pt-2">
<p className="text-sm text-muted-foreground">(chữ ghi họ tên)</p>
{formData.digitalSignatureConfirmed && formData.mainAuthor && (
<p className="font-semibold text-primary mt-2">{formData.mainAuthor}</p>
)}
</div>
</div>
</div>
</section>
{/* Action Buttons */}
<div className="flex justify-end gap-4 border-t border-border pt-6">
<Button
className="gap-2 shadow-lg"
type="button"
onClick={() => void handleSaveDraft()}
disabled={readOnly || !isValid || !formData.digitalSignatureConfirmed || isSavingDraft}
>
<Save size={18} /> {isSavingDraft ? 'Đang lưu...' : 'Tiếp theo'}
</Button>
</div>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
import {
WorkspaceTabPlaceholder,
type WorkspacePlaceholderProps,
} from "@/components/applicant/initiative-workspace/WorkspaceTabPlaceholder";
/**
* Walking-skeleton placeholder. The real council evaluation form (fe0 barrel into
* `council/evaluation`) is admin/council-coupled and lands with slice 5. It is only
* rendered when the user has `evaluation.view` (admin/editor), so applicants never see it.
*/
export default function InitiativeEvaluationForm(_props: WorkspacePlaceholderProps) {
return <WorkspaceTabPlaceholder title="Phiếu Đánh Giá" slice="slice 5" />;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
import { DEPARTMENT_OPTIONS } from "@ump/shared";
const STATUS_OPTIONS = [
{ value: "pending", label: "Chưa duyệt" },
{ value: "approved", label: "Đã duyệt" },
{ value: "rejected", label: "Không duyệt" },
{ value: "transferred", label: "Chuyển hội đồng" },
{ value: "under_review", label: "Đang đánh giá" },
{ value: "reviewed", label: "Đã đánh giá" },
];
const REVIEW_STATUS_OPTIONS = [
{ value: "not_reviewed", label: "Chưa đánh giá" },
{ value: "under_review", label: "Đang đánh giá" },
{ value: "reviewed", label: "Đã đánh giá" },
];
const ALL_OPTION_VALUE = "__all__";
const GROUP_OPTIONS = [
{
value: "internal_technical_management",
label:
"Nhóm 1 - Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho Đại học Y Dược TP.HCM",
},
{
value: "innovation_from_research",
label: "Nhóm 2.1 - Sáng kiến cải tiến kỹ thuật từ các nghiên cứu khoa học (đã đăng tạp chí/hội nghị)",
},
{
value: "innovation_from_materials",
label: "Nhóm 2.2 - Sáng kiến cải tiến kỹ thuật từ sách, giáo trình, tài liệu tham khảo",
},
];
export { DEPARTMENT_OPTIONS, STATUS_OPTIONS, REVIEW_STATUS_OPTIONS, GROUP_OPTIONS, ALL_OPTION_VALUE };
@@ -0,0 +1,30 @@
import { Link } from "react-router-dom";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
export interface MenuItemDescriptor {
title: string;
icon: React.ComponentType<{ className?: string }>;
url: string;
}
interface ApplicantSidebarMenuItemProps {
item: MenuItemDescriptor;
pathname: string;
}
export function ApplicantSidebarMenuItem({ item, pathname }: ApplicantSidebarMenuItemProps) {
const Icon = item.icon;
const active = pathname === item.url;
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={active} tooltip={item.title}>
<Link to={item.url} className="flex mt-4 w-full items-center gap-2">
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
@@ -0,0 +1,265 @@
import { Button, Checkbox, Input, Label, Textarea, type BanCamKet, createEmptyBanCamKet } from '@ump/shared';
export interface AuthorArticleCommitmentFormProps {
value: BanCamKet;
onChange: (next: BanCamKet) => void;
readOnly?: boolean;
/** Từ Đơn: tên tác giả chính, đơn vị, năm nộp — dùng cho nút “Điền nhanh”. */
quickFill: {
authorName: string;
unitName: string;
submissionDay: number;
submissionMonth: number;
submissionYear: string;
};
}
function patch(b: BanCamKet, partial: Partial<BanCamKet>): BanCamKet {
return { ...b, ...partial };
}
function patchNgay(b: BanCamKet, key: keyof BanCamKet['ngay_ky'], v: string): BanCamKet {
return { ...b, ngay_ky: { ...b.ngay_ky, [key]: v } };
}
function patchCamKet(b: BanCamKet, key: keyof BanCamKet['cam_ket'], checked: boolean): BanCamKet {
return { ...b, cam_ket: { ...b.cam_ket, [key]: checked } };
}
export function AuthorArticleCommitmentForm({
value: ban,
onChange,
readOnly = false,
quickFill,
}: AuthorArticleCommitmentFormProps) {
const pickRole = (role: 'tac_gia_chinh' | 'dong_tac_gia') => {
onChange({
...ban,
vai_tro: {
tac_gia_chinh: role === 'tac_gia_chinh',
dong_tac_gia: role === 'dong_tac_gia',
},
});
};
const fillFromApplication = () => {
const y = quickFill.submissionYear.trim() || new Date().getFullYear().toString();
onChange(
patch(ban, {
tac_gia_dang_ky: quickFill.authorName.trim() || ban.tac_gia_dang_ky,
nguoi_cam_ket: quickFill.authorName.trim() || ban.nguoi_cam_ket,
don_vi: quickFill.unitName.trim() || ban.don_vi,
nam_xet: /^\d{4}$/.test(y) ? y : ban.nam_xet,
ngay_ky: {
ngay: String(quickFill.submissionDay),
thang: String(quickFill.submissionMonth),
nam: y,
},
}),
);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Bản cam kết (tác giả bài báo khoa học)</p>
{!readOnly && (
<Button type="button" variant="secondary" size="sm" onClick={fillFromApplication}>
Điền nhanh từ Đơn
</Button>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Điền đy đ theo biểu mẫu: ngày , thông tin tác giả, vai trò với bài báo các mục cam kết bắt buộc.
</p>
<div className="space-y-1.5">
<Label className="text-xs font-medium">Ngày </Label>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Ngày</span>
<Input
className="h-8 w-14 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={ban.ngay_ky.ngay}
onChange={(e) => onChange(patchNgay(ban, 'ngay', e.target.value))}
/>
<span className="text-muted-foreground">Tháng</span>
<Input
className="h-8 w-14 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={ban.ngay_ky.thang}
onChange={(e) => onChange(patchNgay(ban, 'thang', e.target.value))}
/>
<span className="text-muted-foreground">Năm</span>
<Input
className="h-8 w-20 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={ban.ngay_ky.nam}
onChange={(e) => onChange(patchNgay(ban, 'nam', e.target.value))}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-1">
<div className="space-y-1.5">
<Label htmlFor="bck-tacgia">Tác giả đăng sáng kiến</Label>
<Input
id="bck-tacgia"
className="bg-white border-primary/20"
disabled={readOnly}
value={ban.tac_gia_dang_ky}
onChange={(e) => onChange(patch(ban, { tac_gia_dang_ky: e.target.value }))}
maxLength={120}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bck-cccd">CCCD / Hộ chiếu số</Label>
<Input
id="bck-cccd"
className="bg-white border-primary/20"
disabled={readOnly}
value={ban.cccd}
onChange={(e) => onChange(patch(ban, { cccd: e.target.value }))}
placeholder="812 ký tự số"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bck-donvi">Đơn vị</Label>
<Input
id="bck-donvi"
className="bg-white border-primary/20"
disabled={readOnly}
value={ban.don_vi}
onChange={(e) => onChange(patch(ban, { don_vi: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bck-tenbai">Tên bài báo</Label>
<Textarea
id="bck-tenbai"
rows={2}
className="min-h-[3.5rem] bg-white border-primary/20"
disabled={readOnly}
value={ban.ten_bai_bao}
onChange={(e) => onChange(patch(ban, { ten_bai_bao: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bck-namxet">Năm xét</Label>
<Input
id="bck-namxet"
type="text"
inputMode="numeric"
className="max-w-[8rem] bg-white border-primary/20"
disabled={readOnly}
value={ban.nam_xet}
onChange={(e) => onChange(patch(ban, { nam_xet: e.target.value }))}
placeholder="YYYY"
/>
</div>
</div>
<fieldset className="space-y-2 rounded-md border border-border/60 p-3" disabled={readOnly}>
<legend className="text-sm font-medium px-1">Vai trò đi với bài báo</legend>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={ban.vai_tro.tac_gia_chinh}
disabled={readOnly}
onCheckedChange={() => pickRole('tac_gia_chinh')}
/>
Tác giả chính
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={ban.vai_tro.dong_tac_gia}
disabled={readOnly}
onCheckedChange={() => pickRole('dong_tac_gia')}
/>
Đng tác giả
</label>
</div>
</fieldset>
<fieldset className="space-y-3 rounded-md border border-border/60 p-3" disabled={readOnly}>
<legend className="text-sm font-medium px-1">Cam kết nội dung</legend>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={ban.cam_ket.quyen_so_huu_1}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCamKet(ban, 'quyen_so_huu_1', c === true))}
/>
<span>
Tôi chủ sở hữu hợp pháp của bài báo (hoặc đưc y quyền hợp lệ) theo quy đnh về sở hữu trí tuệ.
</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={ban.cam_ket.quyen_so_huu_2}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCamKet(ban, 'quyen_so_huu_2', c === true))}
/>
<span>
Trường hợp bài báo sản phẩm của nhiệm vụ NCKH / đ tài: tôi xác nhận đúng quy đnh về quyền
trách nhiệm của đơn vị chủ trì.
</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={ban.cam_ket.dong_thuan}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCamKet(ban, 'dong_thuan', c === true))}
/>
<span>Tất cả đng tác giả đã biết, đng ý với nội dung đăng .</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={ban.cam_ket.bai_bao_uy_tin}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCamKet(ban, 'bai_bao_uy_tin', c === true))}
/>
<span>Cam kết bài báo không thuộc &quot;Tạp chí săn mồi&quot; (predatory).</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={ban.cam_ket.tuan_thu_phap_luat}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCamKet(ban, 'tuan_thu_phap_luat', c === true))}
/>
<span>Tuân thủ pháp luật về sở hữu trí tuệ.</span>
</label>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="bck-nguoicamket">Người cam kết ( tên)</Label>
<Input
id="bck-nguoicamket"
className="bg-white border-primary/20"
disabled={readOnly}
value={ban.nguoi_cam_ket}
onChange={(e) => onChange(patch(ban, { nguoi_cam_ket: e.target.value }))}
/>
</div>
{!readOnly && (
<Button
type="button"
variant="outline"
size="sm"
className="text-muted-foreground"
onClick={() => onChange(createEmptyBanCamKet())}
>
Xóa nội dung bản cam kết
</Button>
)}
</div>
);
}
@@ -0,0 +1,124 @@
import { Database, FileText, FileStack, Bell, Settings, HelpCircle, LogOut, User } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth, getRoleDisplayName, type Role } from "@ump/shared";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
useSidebar,
} from "@/components/ui/sidebar";
import {
ApplicantSidebarMenuItem,
type MenuItemDescriptor,
} from "@/components/applicant/ApplicantSidebarMenuItem";
const LOGO_SRC = "/logo.png";
// Static applicant menu — mirrors fe0's ApplicantDashboardSidebar item list verbatim.
// (fe0 filters these through a useMyMenuPermissions hook that does not exist in this app;
// the applicant always sees this full set, so it is hardcoded here.)
const mainMenuItems: MenuItemDescriptor[] = [
{ title: "Bộ dữ liệu nhóm", icon: Database, url: "/dashboard/datasets" },
{ title: "Lịch sử đăng ký", icon: FileText, url: "/dashboard/documents" },
{ title: "Mẫu tài liệu", icon: FileStack, url: "/dashboard/templates" },
{ title: "Hồ sơ cá nhân", icon: User, url: "/applicant/profile" },
];
const systemItems: MenuItemDescriptor[] = [
{ title: "Thông báo", icon: Bell, url: "/dashboard/notifications" },
{ title: "Cài đặt", icon: Settings, url: "/dashboard/settings" },
{ title: "Trợ giúp", icon: HelpCircle, url: "/dashboard/help" },
];
export function ApplicantDashboardSidebar() {
const location = useLocation();
const navigate = useNavigate();
const { state } = useSidebar();
const { user, roles, logout } = useAuth();
const isCollapsed = state === "collapsed";
const handleLogout = () => {
logout();
navigate("/login");
};
const roleLabel = roles.map((r: Role) => getRoleDisplayName(r)).join(", ");
return (
<Sidebar collapsible="icon">
<SidebarHeader className="border-b border-sidebar-border p-4">
<Link to="/dashboard" className="flex items-center gap-2">
<img
src={LOGO_SRC}
alt="Đại học Y Dược Thành phố Hồ Chí Minh"
className="h-9 w-9 object-contain flex-shrink-0"
/>
{!isCollapsed && (
<div className="leading-tight">
<div className="font-serif text-sm font-semibold text-sidebar-foreground">
Đi học Y Dược
</div>
<div className="text-[11px] text-muted-foreground">Thành phố Hồ Chí Minh</div>
</div>
)}
</Link>
</SidebarHeader>
<SidebarContent>
{!isCollapsed && (user?.name || roleLabel) && (
<div className="px-4 pt-3">
<p className="truncate text-sm font-medium text-sidebar-foreground">{user?.name}</p>
{roleLabel && (
<p className="truncate text-xs text-sidebar-foreground/70">{roleLabel}</p>
)}
</div>
)}
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{mainMenuItems.map((item) => (
<ApplicantSidebarMenuItem key={item.url} item={item} pathname={location.pathname} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{systemItems.map((item) => (
<ApplicantSidebarMenuItem key={item.url} item={item} pathname={location.pathname} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border p-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={handleLogout}
tooltip="Đăng xuất"
className="text-destructive text-bg-black hover:text-destructive cursor-pointer"
>
<LogOut className="h-4 w-4" />
<span>Đăng xuất</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
@@ -0,0 +1,250 @@
import {
Button,
Checkbox,
Input,
Label,
Textarea,
RESEARCH_DOMESTIC_HONESTY_TIEU_DE_PHU_TEMPLATE_KEY,
type ResearchDomesticHonesty,
createEmptyResearchDomesticHonesty,
} from '@ump/shared';
export interface DomesticJournalHonestyFormProps {
value: ResearchDomesticHonesty;
onChange: (next: ResearchDomesticHonesty) => void;
readOnly?: boolean;
quickFill: {
authorName: string;
unitName: string;
submissionDay: number;
submissionMonth: number;
submissionYear: string;
};
}
function patch(h: ResearchDomesticHonesty, partial: Partial<ResearchDomesticHonesty>): ResearchDomesticHonesty {
return { ...h, ...partial };
}
function patchNgay(
h: ResearchDomesticHonesty,
key: keyof ResearchDomesticHonesty['ngay_ky'],
v: string,
): ResearchDomesticHonesty {
return { ...h, ngay_ky: { ...h.ngay_ky, [key]: v } };
}
function patchCam(
h: ResearchDomesticHonesty,
key: keyof ResearchDomesticHonesty['cam_ket'],
checked: boolean,
): ResearchDomesticHonesty {
return { ...h, cam_ket: { ...h.cam_ket, [key]: checked } };
}
/** Biểu xác nhận — nhóm 2.1.2 (bài báo tạp chí trong nước): PDF minh chứng + cam kết trung thực (JSON tab Đơn). */
export function DomesticJournalHonestyForm({
value: r,
onChange,
readOnly = false,
quickFill,
}: DomesticJournalHonestyFormProps) {
const fillFromApplication = () => {
const y = quickFill.submissionYear.trim() || new Date().getFullYear().toString();
onChange(
patch(r, {
tac_gia_dang_ky: quickFill.authorName.trim() || r.tac_gia_dang_ky,
nguoi_cam_ket: quickFill.authorName.trim() || r.nguoi_cam_ket,
don_vi: quickFill.unitName.trim() || r.don_vi,
nam_xet: /^\d{4}$/.test(y) ? y : r.nam_xet,
ngay_ky: {
ngay: String(quickFill.submissionDay),
thang: String(quickFill.submissionMonth),
nam: y,
},
}),
);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Bản xác nhận bài báo trong nước (2.1.2)</p>
{!readOnly && (
<Button type="button" variant="secondary" size="sm" onClick={fillFromApplication}>
Điền nhanh từ Đơn
</Button>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Điền đy đ theo biểu mẫu DOCX (mục « BẢN XÁC NHẬN BÀI BÁO TRONG NƯỚC (2.1.2) »). File PDF minh chứng tải tab « Tải PDF bài báo »
(lưu kho MinIO, loại minh chứng nghiên cứu).
</p>
<div className="space-y-1.5">
<Label className="text-xs font-medium">Ngày </Label>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Ngày</span>
<Input
className="h-8 w-14 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={r.ngay_ky.ngay}
onChange={(e) => onChange(patchNgay(r, 'ngay', e.target.value))}
/>
<span className="text-muted-foreground">Tháng</span>
<Input
className="h-8 w-14 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={r.ngay_ky.thang}
onChange={(e) => onChange(patchNgay(r, 'thang', e.target.value))}
/>
<span className="text-muted-foreground">Năm</span>
<Input
className="h-8 w-20 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={r.ngay_ky.nam}
onChange={(e) => onChange(patchNgay(r, 'nam', e.target.value))}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="dj-tieude" className="text-xs font-medium leading-snug">
{RESEARCH_DOMESTIC_HONESTY_TIEU_DE_PHU_TEMPLATE_KEY}
</Label>
<Textarea
id="dj-tieude"
rows={2}
className="min-h-[2.75rem] bg-white border-primary/20 text-sm"
disabled={readOnly}
value={r.tieu_de_phu}
onChange={(e) => onChange(patch(r, { tieu_de_phu: e.target.value }))}
placeholder="Tuỳ chọn — nếu mẫu in có dòng tiêu đề phụ"
/>
</div>
<div className="grid gap-3 sm:grid-cols-1">
<p className="text-xs font-semibold text-foreground">I. THÔNG TIN ĐĂNG </p>
<div className="space-y-1.5">
<Label htmlFor="dj-tacgia">Tác giả đăng sáng kiến</Label>
<Input
id="dj-tacgia"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.tac_gia_dang_ky}
onChange={(e) => onChange(patch(r, { tac_gia_dang_ky: e.target.value }))}
maxLength={120}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dj-cccd">CCCD/Hộ chiếu số</Label>
<Input
id="dj-cccd"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.cccd}
onChange={(e) => onChange(patch(r, { cccd: e.target.value }))}
placeholder="812 ký tự số"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dj-donvi">Đơn vị</Label>
<Input
id="dj-donvi"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.don_vi}
onChange={(e) => onChange(patch(r, { don_vi: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dj-tenbai">Tên bài báo (tạp chí trong nước, giai đoạn xuất bản quy đnh)</Label>
<Textarea
id="dj-tenbai"
rows={2}
className="min-h-[3.5rem] bg-white border-primary/20"
disabled={readOnly}
value={r.ten_bai_bao}
onChange={(e) => onChange(patch(r, { ten_bai_bao: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dj-namxet">Năm xét công nhận sáng kiến</Label>
<Input
id="dj-namxet"
type="text"
inputMode="numeric"
className="max-w-[8rem] bg-white border-primary/20"
disabled={readOnly}
value={r.nam_xet}
onChange={(e) => onChange(patch(r, { nam_xet: e.target.value }))}
placeholder="YYYY"
/>
</div>
</div>
<fieldset className="space-y-3 rounded-md border border-border/60 p-3" disabled={readOnly}>
<legend className="text-sm font-medium px-1">II. XÁC NHẬN CAM KẾT ( vào ô tương ng)</legend>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={r.cam_ket.thong_tin_trung_thuc}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCam(r, 'thong_tin_trung_thuc', c === true))}
/>
<span>
Tôi cam đoan các thông tin khai minh chứng đính kèm đi với bài báo trên tạp chí trong nước trung
thực, đúng sự thật phù hợp với thời điểm xuất bản trong giai đoạn quy đnh (15/4/202515/4/2026).
</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={r.cam_ket.trach_nhiem_phap_luat}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCam(r, 'trach_nhiem_phap_luat', c === true))}
/>
<span>
Tôi hoàn toàn chịu trách nhiệm trước pháp luật trước nhà trường về tính hợp pháp của bài báo nội dung
đăng .
</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={r.cam_ket.bo_sung_khi_yeu_cau}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCam(r, 'bo_sung_khi_yeu_cau', c === true))}
/>
<span>Tôi đng ý bổ sung hoặc chỉnh sửa hồ khi đưc yêu cầu.</span>
</label>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="dj-nguoicamket">Người cam kết ( tên, ghi họ tên)</Label>
<Input
id="dj-nguoicamket"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.nguoi_cam_ket}
onChange={(e) => onChange(patch(r, { nguoi_cam_ket: e.target.value }))}
/>
</div>
{!readOnly && (
<Button
type="button"
variant="outline"
size="sm"
className="text-muted-foreground"
onClick={() => onChange(createEmptyResearchDomesticHonesty())}
>
Xóa nội dung biểu xác nhận
</Button>
)}
</div>
);
}
@@ -0,0 +1,229 @@
import {
Button,
Checkbox,
Input,
Label,
Textarea,
type ReferenceMaterialHonesty,
createEmptyReferenceMaterialHonesty,
} from '@ump/shared';
export interface ReferenceMaterialHonestyFormProps {
value: ReferenceMaterialHonesty;
onChange: (next: ReferenceMaterialHonesty) => void;
readOnly?: boolean;
quickFill: {
authorName: string;
unitName: string;
submissionDay: number;
submissionMonth: number;
submissionYear: string;
};
}
function patch(h: ReferenceMaterialHonesty, partial: Partial<ReferenceMaterialHonesty>): ReferenceMaterialHonesty {
return { ...h, ...partial };
}
function patchNgay(h: ReferenceMaterialHonesty, key: keyof ReferenceMaterialHonesty['ngay_ky'], v: string): ReferenceMaterialHonesty {
return { ...h, ngay_ky: { ...h.ngay_ky, [key]: v } };
}
function patchCam(
h: ReferenceMaterialHonesty,
key: keyof ReferenceMaterialHonesty['cam_ket'],
checked: boolean,
): ReferenceMaterialHonesty {
return { ...h, cam_ket: { ...h.cam_ket, [key]: checked } };
}
/** Biểu xác nhận — nhóm 2.2.2 (tài liệu tham khảo): PDF minh chứng + các cam kết trung thực (JSON tab Đơn). */
export function ReferenceMaterialHonestyForm({
value: r,
onChange,
readOnly = false,
quickFill,
}: ReferenceMaterialHonestyFormProps) {
const fillFromApplication = () => {
const y = quickFill.submissionYear.trim() || new Date().getFullYear().toString();
onChange(
patch(r, {
tac_gia_dang_ky: quickFill.authorName.trim() || r.tac_gia_dang_ky,
nguoi_cam_ket: quickFill.authorName.trim() || r.nguoi_cam_ket,
don_vi: quickFill.unitName.trim() || r.don_vi,
nam_xet: /^\d{4}$/.test(y) ? y : r.nam_xet,
ngay_ky: {
ngay: String(quickFill.submissionDay),
thang: String(quickFill.submissionMonth),
nam: y,
},
}),
);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Biểu xác nhận tài liệu tham khảo (2.2.2)</p>
{!readOnly && (
<Button type="button" variant="secondary" size="sm" onClick={fillFromApplication}>
Điền nhanh từ Đơn
</Button>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Điền đy đ thông tin chọn các nội dung cam kết. Minh chứng PDF (Quyết đnh xuất bản, bìa, mục lục) tải tab
« Tải PDF ».
</p>
<div className="space-y-1.5">
<Label className="text-xs font-medium">Ngày </Label>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Ngày</span>
<Input
className="h-8 w-14 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={r.ngay_ky.ngay}
onChange={(e) => onChange(patchNgay(r, 'ngay', e.target.value))}
/>
<span className="text-muted-foreground">Tháng</span>
<Input
className="h-8 w-14 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={r.ngay_ky.thang}
onChange={(e) => onChange(patchNgay(r, 'thang', e.target.value))}
/>
<span className="text-muted-foreground">Năm</span>
<Input
className="h-8 w-20 bg-white border-primary/20 text-center"
inputMode="numeric"
disabled={readOnly}
value={r.ngay_ky.nam}
onChange={(e) => onChange(patchNgay(r, 'nam', e.target.value))}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-1">
<div className="space-y-1.5">
<Label htmlFor="rm-tacgia">Tác giả đăng sáng kiến</Label>
<Input
id="rm-tacgia"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.tac_gia_dang_ky}
onChange={(e) => onChange(patch(r, { tac_gia_dang_ky: e.target.value }))}
maxLength={120}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rm-cccd">CCCD / Hộ chiếu số</Label>
<Input
id="rm-cccd"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.cccd}
onChange={(e) => onChange(patch(r, { cccd: e.target.value }))}
placeholder="812 ký tự số"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rm-donvi">Đơn vị</Label>
<Input
id="rm-donvi"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.don_vi}
onChange={(e) => onChange(patch(r, { don_vi: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rm-tentailieu">Tên tài liệu (theo Quyết đnh xuất bản)</Label>
<Textarea
id="rm-tentailieu"
rows={2}
className="min-h-[3.5rem] bg-white border-primary/20"
disabled={readOnly}
value={r.ten_tai_lieu}
onChange={(e) => onChange(patch(r, { ten_tai_lieu: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rm-namxet">Năm xét công nhận sáng kiến</Label>
<Input
id="rm-namxet"
type="text"
inputMode="numeric"
className="max-w-[8rem] bg-white border-primary/20"
disabled={readOnly}
value={r.nam_xet}
onChange={(e) => onChange(patch(r, { nam_xet: e.target.value }))}
placeholder="YYYY"
/>
</div>
</div>
<fieldset className="space-y-3 rounded-md border border-border/60 p-3" disabled={readOnly}>
<legend className="text-sm font-medium px-1">Xác nhận cam kết</legend>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={r.cam_ket.thong_tin_trung_thuc}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCam(r, 'thong_tin_trung_thuc', c === true))}
/>
<span>
Tôi cam đoan các thông tin khai minh chứng đính kèm đi với tài liệu tham khảo trung thực, đúng sự
thật phù hợp với Quyết đnh xuất bản trong giai đoạn quy đnh (15/4/202515/4/2026).
</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={r.cam_ket.trach_nhiem_phap_luat}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCam(r, 'trach_nhiem_phap_luat', c === true))}
/>
<span>
Tôi hoàn toàn chịu trách nhiệm trước pháp luật trước nhà trường về tính hợp pháp của tài liệu nội dung
đăng .
</span>
</label>
<label className="flex items-start gap-2 text-sm leading-snug cursor-pointer">
<Checkbox
className="mt-0.5"
checked={r.cam_ket.bo_sung_khi_yeu_cau}
disabled={readOnly}
onCheckedChange={(c) => onChange(patchCam(r, 'bo_sung_khi_yeu_cau', c === true))}
/>
<span>Tôi đng ý bổ sung hoặc chỉnh sửa hồ khi đưc yêu cầu.</span>
</label>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="rm-nguoicamket">Người cam kết ( tên)</Label>
<Input
id="rm-nguoicamket"
className="bg-white border-primary/20"
disabled={readOnly}
value={r.nguoi_cam_ket}
onChange={(e) => onChange(patch(r, { nguoi_cam_ket: e.target.value }))}
/>
</div>
{!readOnly && (
<Button
type="button"
variant="outline"
size="sm"
className="text-muted-foreground"
onClick={() => onChange(createEmptyReferenceMaterialHonesty())}
>
Xóa nội dung biểu xác nhận
</Button>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import {
DEPARTMENT_OPTIONS,
isKnownDepartmentWorkplace,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
cn,
} from '@ump/shared';
/** Radix Select cannot use empty string as a value; this stands for “no unit selected”. */
const EMPTY_VALUE = '__workplace_none__';
export type WorkplaceSelectProps = {
value: string;
onValueChange: (workplace: string) => void;
disabled?: boolean;
/** Merged into SelectTrigger (table cells use border-b-only styling). */
className?: string;
};
/**
* “Nơi công tác” — stores the Vietnamese unit name in form state (same as legacy free-text),
* so DOCX / PDF pipelines need no mapping.
*/
export function WorkplaceSelect({ value, onValueChange, disabled, className }: WorkplaceSelectProps) {
const hasOrphan =
value.length > 0 && !isKnownDepartmentWorkplace(value);
const selectValue = value.length === 0 ? EMPTY_VALUE : value;
return (
<Select
value={selectValue}
onValueChange={(v) => onValueChange(v === EMPTY_VALUE ? '' : v)}
disabled={disabled}
>
<SelectTrigger
className={cn(
'h-9 border-0 border-b border-border rounded-none px-1 shadow-none focus:ring-0 focus:ring-offset-0',
className,
)}
>
<SelectValue placeholder="Chọn đơn vị" />
</SelectTrigger>
<SelectContent position="popper" className="max-h-[min(24rem,70vh)]">
<SelectItem value={EMPTY_VALUE}> Chọn đơn vị </SelectItem>
{hasOrphan ? (
<SelectItem value={value} className="text-muted-foreground">
{value} (không trong danh sách)
</SelectItem>
) : null}
{DEPARTMENT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.label}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,230 @@
import { useCallback, useMemo, useState } from "react";
import { Lock, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
ResizablePanel,
ResizablePanelGroup,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
useAuth,
cn,
reviveContributionDraft,
DraftProvider,
REPORT_STEP_SESSION_KEY,
type ReviewDraftTabs,
type ApplicationFormState,
type InitiativeFormState,
} from "@ump/shared";
import InitiativeReportForm from "@/components/InitiativeReportForm";
import InitiativeApplicationForm from "@/components/InitiativeApplicationForm";
import ContributionConfirmationForm from "@/components/ContributionConfirmationForm";
import InitiativeEvaluationForm from "@/components/InitiativeEvaluationForm";
import { ReviewPanel } from "@/components/applicant/initiative-draft/ReviewPanel";
import { useApplicantRegistrationState } from "@/hooks/useApplicantRegistrationState";
/** Debounced POST per tab to `/api/v1/application-drafts` so drafts exist before « Tiếp theo » and stay in sync for Lịch sử (mở / sửa / xóa). */
const DRAFT_AUTOSAVE_MS = 2000;
/**
* Applicant-only workspace: Báo cáo → Đơn → Đóng góp → Xem lại (drafts persisted to Postgres via `/api/v1/application-drafts`).
*/
export function ApplicantRegistrationDashboard() {
const {
applicationCaseId,
draftTabs,
reportStepCompleted,
activeApplicantTab,
setActiveApplicantTab,
applicantDraftMountKey,
draftLoadStatus,
persistDraft,
persistAllDraftTabs,
handleReportNext,
} = useApplicantRegistrationState();
/** Stable handlers so autosave effects in tab forms do not reset debounce timers on unrelated parent renders. */
const saveReportTabDraft = useCallback(
(data: InitiativeFormState) => persistDraft("report", data as unknown as Record<string, unknown>),
[persistDraft],
);
const saveApplicationTabDraft = useCallback(
(data: ApplicationFormState) =>
persistDraft("application", data as unknown as Record<string, unknown>),
[persistDraft],
);
const saveContributionTabDraft = useCallback(
(data: unknown) => persistDraft("contribution", data as Record<string, unknown>),
[persistDraft],
);
const [verificationRequest, setVerificationRequest] = useState<{
fieldName: string;
content: string;
} | null>(null);
const { hasPermission } = useAuth();
const canViewEvaluation = hasPermission("evaluation.view");
const canViewChat = hasPermission("chat.view");
const canVerify = hasPermission("application.verify");
const canEditApplication = hasPermission("application.edit");
const isViewOnly = !canEditApplication && hasPermission("application.view");
const sessionReportDone =
typeof window !== "undefined" &&
(() => {
try {
return sessionStorage.getItem(REPORT_STEP_SESSION_KEY) === "1";
} catch {
return false;
}
})();
const reportGateOpen = reportStepCompleted || sessionReportDone;
const lockTabsAfterReport = canEditApplication && !reportGateOpen;
const handleApplicantTabChange = (value: string) => {
if (value === "report" || !lockTabsAfterReport) {
setActiveApplicantTab(value);
return;
}
toast.error(
"Vui lòng hoàn thành tab Báo cáo, xác nhận cam kết và bấm « Tiếp theo » trước khi mở tab khác.",
);
};
const handleVerify = useCallback(
(fieldName: string, content: string) => {
if (canVerify) setVerificationRequest({ fieldName, content });
},
[canVerify],
);
const lockedTabTriggerClass = lockTabsAfterReport
? "pointer-events-none cursor-not-allowed opacity-50"
: undefined;
const mainPanelSize = canViewChat ? 70 : 100;
const chatPanelSize = canViewChat ? 30 : 0;
const showDraftLoading = Boolean(applicationCaseId) && draftLoadStatus === "loading";
const reviewFallbackDraftTabs = useMemo(
(): ReviewDraftTabs => ({
application: draftTabs.application,
report: draftTabs.report,
}),
[draftTabs.application, draftTabs.report],
);
return (
<div className="flex h-[calc(100vh-4rem)] overflow-hidden animate-fade-in">
{draftLoadStatus === "error" ? (
<div
className="border-b border-destructive/40 bg-destructive/10 px-4 py-2 text-center text-sm text-destructive"
role="alert"
>
Không tải đưc bản nháp từ máy chủ. Hãy tải lại trang hoặc đăng nhập lại.
</div>
) : null}
{showDraftLoading ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/80 backdrop-blur-[1px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">Đang tải bản nháp từ máy chủ</p>
</div>
) : null}
<ResizablePanelGroup direction="horizontal" className="h-full overflow-hidden">
<ResizablePanel defaultSize={mainPanelSize} minSize={0}>
<div className="h-full overflow-auto p-6">
<DraftProvider
key={applicantDraftMountKey}
caseId={applicationCaseId || undefined}
seedApplication={(draftTabs.application ?? undefined) as Record<string, unknown> | undefined}
seedReport={(draftTabs.report ?? undefined) as Record<string, unknown> | undefined}
preferLocalSnapshot={false}
>
<Tabs
value={activeApplicantTab}
onValueChange={handleApplicantTabChange}
className="w-full"
key={reportGateOpen ? "report-gate-open" : "report-gate-locked"}
>
<TabsList className="mb-4 flex-wrap h-auto gap-1">
<TabsTrigger value="report">Báo cáo tả Sáng kiến</TabsTrigger>
<TabsTrigger value="application" className={lockedTabTriggerClass} aria-disabled={lockTabsAfterReport}>
Đơn Đ nghị Công nhận
</TabsTrigger>
<TabsTrigger value="contribution" className={lockedTabTriggerClass} aria-disabled={lockTabsAfterReport}>
Xác nhận Tỷ lệ Đóng góp
</TabsTrigger>
<TabsTrigger value="review" className={lockedTabTriggerClass} aria-disabled={lockTabsAfterReport}>
Xem lại & tải PDF
</TabsTrigger>
{canViewEvaluation && (
<TabsTrigger
value="evaluation"
className={cn("gap-1", lockedTabTriggerClass)}
aria-disabled={lockTabsAfterReport}
>
<Lock size={14} />
Phiếu Đánh Giá
</TabsTrigger>
)}
</TabsList>
<TabsContent value="report">
<InitiativeReportForm
onVerify={canVerify ? handleVerify : undefined}
readOnly={isViewOnly}
showVerifyButton={canVerify}
initialDraft={draftTabs.report as Partial<InitiativeFormState> | undefined}
onSaveDraft={saveReportTabDraft}
autoSaveDebounceMs={isViewOnly ? undefined : DRAFT_AUTOSAVE_MS}
onNext={handleReportNext}
/>
</TabsContent>
<TabsContent value="application">
<InitiativeApplicationForm
onVerify={canVerify ? handleVerify : undefined}
readOnly={isViewOnly}
showVerifyButton={canVerify}
initialDraft={draftTabs.application as Partial<ApplicationFormState> | undefined}
onSaveDraft={saveApplicationTabDraft}
autoSaveDebounceMs={isViewOnly ? undefined : DRAFT_AUTOSAVE_MS}
onNext={() => setActiveApplicantTab("contribution")}
/>
</TabsContent>
<TabsContent value="contribution">
<ContributionConfirmationForm
onVerify={canVerify ? handleVerify : undefined}
readOnly={isViewOnly}
showVerifyButton={canVerify}
initialDraft={reviveContributionDraft(draftTabs.contribution)}
onSaveDraft={saveContributionTabDraft}
autoSaveDebounceMs={isViewOnly ? undefined : DRAFT_AUTOSAVE_MS}
onNext={() => setActiveApplicantTab("review")}
/>
</TabsContent>
<TabsContent value="review">
<ReviewPanel
caseId={applicationCaseId || undefined}
contributionDraft={draftTabs.contribution}
fallbackDraftTabs={reviewFallbackDraftTabs}
onSaveAllDrafts={persistAllDraftTabs}
/>
</TabsContent>
{canViewEvaluation && (
<TabsContent value="evaluation">
<InitiativeEvaluationForm onVerify={canVerify ? handleVerify : undefined} />
</TabsContent>
)}
</Tabs>
</DraftProvider>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Button,
Input,
Label,
formatIsoToDdMmYyyy,
normalizeDdMmYyyyInput,
parseDdMmYyyy,
} from "@ump/shared";
import type { ApplicationItem } from "@/data/mockApplications";
type Mode = "create" | "edit";
function ddMmYyyyToSubmittedIso(s: string): string {
const d = parseDdMmYyyy(s.trim());
if (!d) return new Date().toISOString();
d.setHours(12, 0, 0, 0);
return d.toISOString();
}
export interface ApplicantHistoryCrudDialogProps {
open: boolean;
mode: Mode;
initialItem?: ApplicationItem;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: { name: string; submittedDate: string }) => void;
}
export function ApplicantHistoryCrudDialog({
open,
mode,
initialItem,
submitting,
onOpenChange,
onSubmit,
}: ApplicantHistoryCrudDialogProps) {
const [name, setName] = useState("");
const [dateInput, setDateInput] = useState("");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initialItem) {
setName(initialItem.name ?? "");
setDateInput(formatIsoToDdMmYyyy(initialItem.submittedDate));
} else {
setName("");
setDateInput(formatIsoToDdMmYyyy(new Date().toISOString()));
}
}, [open, mode, initialItem]);
const title = mode === "create" ? "Đăng ký hồ sơ mới" : "Cập nhật thông tin hồ sơ";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="app-crud-name">Tên sáng kiến</Label>
<Input
id="app-crud-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tên sáng kiến"
disabled={submitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="app-crud-date">Ngày nộp</Label>
<Input
id="app-crud-date"
type="text"
inputMode="numeric"
placeholder="dd/mm/yyyy"
maxLength={10}
value={dateInput}
onChange={(e) => setDateInput(normalizeDdMmYyyyInput(e.target.value))}
disabled={submitting}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Hủy
</Button>
<Button
type="button"
onClick={() => onSubmit({ name, submittedDate: ddMmYyyyToSubmittedIso(dateInput) })}
disabled={submitting || !name.trim()}
>
{submitting ? "Đang lưu…" : mode === "create" ? "Tạo hồ sơ" : "Cập nhật"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,241 @@
import { useMemo, useState } from "react";
import { LayoutDashboard } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Button,
} from "@ump/shared";
import type { ApplicationItem } from "@/data/mockApplications";
import { STATUS_OPTIONS, ALL_OPTION_VALUE } from "@/components/admin/SelectOptions";
import { ApplicantHistoryTable } from "@/components/applicant/history/ApplicantHistoryTable";
import type { EvidenceFileSlotKey } from "@/components/evidence/EvidenceFilesTableCell";
const APPLICANT_HISTORY_EVIDENCE_HIDE_KEYS: readonly EvidenceFileSlotKey[] = ["fullText"];
type SortBy = "submittedDate" | "name" | "status";
type SortOrder = "asc" | "desc";
interface ApplicantHistoryPanelProps {
items: ApplicationItem[];
loading: boolean;
error: boolean;
mutating?: boolean;
selectedApplicationId?: string;
/** Mở xem biểu mẫu đã nộp (chỉ đọc) */
onOpenApplication?: (item: ApplicationItem) => void;
/** Cùng nghĩa `onOpenApplication` (tương thích `ApplicationReviewPage`) */
onViewApplication?: (item: ApplicationItem) => void;
onCreateApplication?: () => void;
/** Sửa biểu mẫu từ bản nháp Postgres (chuyển tới dashboard với mã case) */
onUpdateApplicationForms?: (item: ApplicationItem) => void;
/** Cùng nghĩa `onUpdateApplicationForms` */
onEditApplicationForms?: (item: ApplicationItem) => void;
/** Sửa tên hồ sơ / ngày nộp (hộp thoại) */
onEditApplicationMetadata?: (item: ApplicationItem) => void;
onDeleteApplication?: (applicationId: string) => Promise<unknown> | void;
}
export function ApplicantHistoryPanel({
items,
loading,
error,
mutating = false,
selectedApplicationId,
onOpenApplication,
onViewApplication,
onCreateApplication,
onUpdateApplicationForms,
onEditApplicationForms,
onEditApplicationMetadata,
onDeleteApplication,
}: ApplicantHistoryPanelProps) {
const openApplication = onOpenApplication ?? onViewApplication;
const updateApplicationForms = onUpdateApplicationForms ?? onEditApplicationForms;
const [nameFilter, setNameFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [yearFilter, setYearFilter] = useState<string>("");
const [sortBy, setSortBy] = useState<SortBy>("submittedDate");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const availableYears = useMemo(() => {
const years = new Set<string>();
for (const item of items) {
const year = typeof item.calendarYear === "number"
? String(item.calendarYear)
: String(new Date(item.submittedDate).getFullYear());
if (year && year !== "NaN") years.add(year);
}
return Array.from(years).sort((a, b) => Number(b) - Number(a));
}, [items]);
const filteredAndSorted = useMemo(() => {
const keyword = nameFilter.trim().toLowerCase();
const rows = items.filter((item) => {
if (keyword && !item.name.toLowerCase().includes(keyword)) return false;
if (statusFilter && item.status !== statusFilter) return false;
if (yearFilter) {
const y = typeof item.calendarYear === "number"
? String(item.calendarYear)
: String(new Date(item.submittedDate).getFullYear());
if (y !== yearFilter) return false;
}
return true;
});
rows.sort((a, b) => {
const order = sortOrder === "asc" ? 1 : -1;
if (sortBy === "name") return a.name.localeCompare(b.name) * order;
if (sortBy === "status") return String(a.status).localeCompare(String(b.status)) * order;
return (new Date(a.submittedDate).getTime() - new Date(b.submittedDate).getTime()) * order;
});
return rows;
}, [items, nameFilter, statusFilter, yearFilter, sortBy, sortOrder]);
const totalItems = filteredAndSorted.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(page, totalPages);
const pagedRows = filteredAndSorted.slice((safePage - 1) * pageSize, safePage * pageSize);
const resetFilters = () => {
setNameFilter("");
setStatusFilter("");
setYearFilter("");
setSortBy("submittedDate");
setSortOrder("desc");
setPage(1);
};
const handleDelete = async (item: ApplicationItem) => {
if (!onDeleteApplication) return;
await onDeleteApplication(item.id);
};
const toggleSort = (nextSortBy: SortBy) => {
if (sortBy === nextSortBy) {
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
} else {
setSortBy(nextSortBy);
setSortOrder("desc");
}
setPage(1);
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Lịch sử hồ đã nộp của tôi</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="history-name">Tên sáng kiến</Label>
<Input
id="history-name"
value={nameFilter}
onChange={(e) => {
setNameFilter(e.target.value);
setPage(1);
}}
placeholder="Nhập tên sáng kiến"
/>
</div>
<div className="space-y-2">
<Label>Trạng thái</Label>
<Select
value={statusFilter || ALL_OPTION_VALUE}
onValueChange={(v) => {
setStatusFilter(v === ALL_OPTION_VALUE ? "" : v);
setPage(1);
}}
>
<SelectTrigger><SelectValue placeholder="Chọn" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_OPTION_VALUE}>Tất cả</SelectItem>
{STATUS_OPTIONS.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Năm nộp</Label>
<Select
value={yearFilter || ALL_OPTION_VALUE}
onValueChange={(v) => {
setYearFilter(v === ALL_OPTION_VALUE ? "" : v);
setPage(1);
}}
>
<SelectTrigger><SelectValue placeholder="Chọn năm" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_OPTION_VALUE}>Tất cả</SelectItem>
{availableYears.map((year) => <SelectItem key={year} value={year}>{year}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={() => setPage(1)}>Tìm</Button>
<Button className="bg-black text-white" variant="ghost" onClick={resetFilters}>Xóa bộ lọc</Button>
<Button
type="button"
className="gap-2"
onClick={onCreateApplication}
>
<LayoutDashboard className="h-4 w-4" />
Đăng mới
</Button>
</div>
</CardContent>
</Card>
<ApplicantHistoryTable
rows={pagedRows}
loading={loading}
error={error}
mutating={mutating}
selectedApplicationId={selectedApplicationId}
sortBy={sortBy}
onToggleSort={toggleSort}
onOpenApplication={openApplication}
onUpdateApplicationForms={updateApplicationForms}
onEditApplicationMetadata={onEditApplicationMetadata}
onDeleteApplication={onDeleteApplication ? handleDelete : undefined}
evidenceHideFileKeys={APPLICANT_HISTORY_EVIDENCE_HIDE_KEYS}
/>
<div className="flex flex-col justify-between gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center">
<div>Trang {safePage} / {totalPages} | Hiển thị {pageSize} / {totalItems} kết quả</div>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v));
setPage(1);
}}
>
<SelectTrigger className="w-[90px]"><SelectValue /></SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((size) => <SelectItem key={size} value={String(size)}>{size}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={safePage <= 1}>Trước</Button>
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={safePage >= totalPages}>Sau</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,228 @@
import { useState } from "react";
import { ArrowUpDown, Eye, Trash2, NotebookPen } from "lucide-react";
import {
Badge,
Button,
buttonVariants,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
formatIsoToDdMmYyyy,
formatIsoToDdMmYyyyHhMm,
} from "@ump/shared";
import type { ApplicationItem, ApplicationStatus } from "@/data/mockApplications";
import { EvidenceFilesTableCell, type EvidenceFileSlotKey } from "@/components/evidence/EvidenceFilesTableCell";
import { STATUS_OPTIONS } from "@/components/admin/SelectOptions";
type SortBy = "submittedDate" | "name" | "status";
function formatDate(iso: string | null | undefined, withTime = false) {
if (!iso) return "";
if (withTime) return formatIsoToDdMmYyyyHhMm(iso);
return formatIsoToDdMmYyyy(iso);
}
function optionLabel(options: ReadonlyArray<{ value: string; label: string }>, value?: string | null) {
if (value == null || value === "") return "";
return options.find((o) => o.value === value)?.label ?? "";
}
function statusBadge(status: ApplicationStatus) {
const label = optionLabel(STATUS_OPTIONS, status) || String(status);
switch (status) {
case "pending":
return <Badge className="bg-amber-500 hover:bg-amber-500/90">{label}</Badge>;
case "approved":
return <Badge className="bg-green-600 hover:bg-green-600/90">{label}</Badge>;
case "rejected":
return <Badge variant="destructive">{label}</Badge>;
case "transferred":
return <Badge className="bg-blue-600 hover:bg-blue-600/90">{label}</Badge>;
case "under_review":
return <Badge variant="secondary">{label}</Badge>;
case "reviewed":
return <Badge variant="outline" className="border-green-500 text-green-700">{label}</Badge>;
default:
return <Badge variant="secondary">-</Badge>;
}
}
function reviewStatusBadge(reviewStatus?: string) {
switch (reviewStatus) {
case "reviewed":
return <Badge className="bg-green-600 hover:bg-green-600/90">Đã đánh giá</Badge>;
case "under_review":
return <Badge variant="secondary">Đang đánh giá</Badge>;
case "not_reviewed":
return <Badge variant="outline">Chưa đánh giá</Badge>;
default:
return <Badge variant="secondary"></Badge>;
}
}
interface ApplicantHistoryTableProps {
rows: ApplicationItem[];
loading: boolean;
error: boolean;
mutating?: boolean;
selectedApplicationId?: string;
sortBy: SortBy;
onToggleSort: (sortBy: SortBy) => void;
onOpenApplication?: (item: ApplicationItem) => void;
/** Open dashboard with Postgres draft for this hồ sơ (sửa biểu mẫu) */
onUpdateApplicationForms?: (item: ApplicationItem) => void;
/** Tên hồ sơ / ngày nộp */
onEditApplicationMetadata?: (item: ApplicationItem) => void;
onDeleteApplication?: (item: ApplicationItem) => void;
/** Cột minh chứng: ẩn liên kết tới một số loại tệp đã nộp (ví dụ toàn văn). */
evidenceHideFileKeys?: ReadonlyArray<EvidenceFileSlotKey>;
}
export function ApplicantHistoryTable({
rows,
loading,
error,
mutating = false,
selectedApplicationId,
sortBy,
onToggleSort,
onOpenApplication,
onUpdateApplicationForms,
onEditApplicationMetadata,
onDeleteApplication,
evidenceHideFileKeys,
}: ApplicantHistoryTableProps) {
const [deleteTarget, setDeleteTarget] = useState<ApplicationItem | null>(null);
return (
<div className="space-y-0">
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">STT</TableHead>
<TableHead className="w-[160px]">
<button type="button" onClick={() => onToggleSort("submittedDate")} className="inline-flex items-center gap-1">
Ngày gởi {sortBy === "submittedDate" ? <ArrowUpDown className="h-3 w-3" /> : null}
</button>
</TableHead>
<TableHead className="min-w-[260px]">
<button type="button" onClick={() => onToggleSort("name")} className="inline-flex items-center gap-1">
Tên sáng kiến {sortBy === "name" ? <ArrowUpDown className="h-3 w-3" /> : null}
</button>
</TableHead>
<TableHead className="w-[140px]">Trạng thái</TableHead>
<TableHead className="w-[150px]">Trạng thái đánh giá</TableHead>
<TableHead className="w-[60px] text-center">Minh chứng</TableHead>
<TableHead className="w-[230px] text-center">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="py-10 text-center text-muted-foreground">Đang tải dữ liệu...</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={8} className="py-10 text-center text-destructive">Không thể tải lịch sử hồ .</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-10 text-center text-muted-foreground">Không hồ phù hợp bộ lọc.</TableCell>
</TableRow>
) : (
rows.map((item, index) => (
<TableRow key={item.id} data-state={selectedApplicationId === item.id ? "selected" : undefined}>
<TableCell>{index + 1}</TableCell>
<TableCell>{formatDate(item.submittedDate, true)}</TableCell>
<TableCell className="max-w-[320px] truncate font-semibold" title={item.name}>{item.name}</TableCell>
<TableCell>{statusBadge(item.status)}</TableCell>
<TableCell>{reviewStatusBadge(item.reviewStatus)}</TableCell>
<TableCell className="text-center">
<EvidenceFilesTableCell
item={item}
caseId={item.draftCaseId || item.draft_case_id}
hideFileKeys={evidenceHideFileKeys}
/>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-2">
<Button
size="icon"
variant="outline"
onClick={() => onOpenApplication?.(item)}
aria-label="Xem hồ sơ"
disabled={!onOpenApplication}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="default"
onClick={() => onUpdateApplicationForms?.(item)}
aria-label="Cập nhật biểu mẫu từ dữ liệu đã lưu"
disabled={mutating || !onUpdateApplicationForms}
title="Cập nhật biểu mẫu (báo cáo, đơn, đóng góp)"
>
<NotebookPen className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="destructive"
onClick={() => setDeleteTarget(item)}
aria-label="Xóa hồ sơ"
disabled={mutating || !onDeleteApplication}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AlertDialog open={deleteTarget != null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Xóa hồ ?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget ? (
<>
Bạn chắc muốn xóa hồ <span className="font-medium text-foreground">"{deleteTarget.name}"</span>? Thao tác này không
thể hoàn tác. hồ : <span className="font-mono text-xs text-foreground">{deleteTarget.id}</span>.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Hủy</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
if (deleteTarget) void onDeleteApplication?.(deleteTarget);
}}
>
Xóa hồ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,490 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Checkbox,
type InitiativeDraft,
buildOfficialBieuMauFromDraft,
type ContributionDraftShape,
fetchApplicationFormDocxPreview,
fetchApplicationFormOfficialPdf,
persistReviewDocumentBundle,
useAuth,
ApplicationFormOfficialPdfActions,
useOptionalDraft,
collectApplicantSubmitToAdminPrerequisiteGaps,
formatApplicantPrerequisiteToastDescription,
DocxToPdfViewer,
} from '@ump/shared';
import { runInitiativeSubmitWorkflow } from '@/lib/initiativeSubmitWorkflow';
import { saveApplicantSubmissionRecord } from '@/lib/applicantSubmissionRecord';
const STAFF_PREVIEW_EXPLANATION = (
<>
PDF mẫu từ máy chủ (LibreOffice) đng bộ với tệp lưu MinIO khi ng viên nộp; thể dự phòng cục bộ trong trình duyệt.
Nguồn JSON: Đơn / Báo cáo / Xác nhận đóng góp. Dùng{' '}
<span className="font-medium not-italic text-foreground">Tải DOCX</span> khi cần đi chiếu ngoại tuyến. Cuối khối:{' '}
<span className="font-medium not-italic text-foreground">Từ chối</span>,{' '}
<span className="font-medium not-italic text-foreground">Kho minh chứng</span>,{' '}
<span className="font-medium not-italic text-foreground">Duyệt</span>.
</>
);
/** Ban quản trị (chỉ xem): không có nút tải DOCX/PDF. */
const ADMIN_READONLY_PREVIEW_EXPLANATION = (
<>
Dữ liệu Đơn / Báo cáo / Xác nhận đóng góp; PDF chính thức trên máy chủ khớp MinIO sau khi nộp.
Cuối khối:{' '}
<span className="font-medium not-italic text-foreground">Từ chối</span>, mở{' '}
<span className="font-medium not-italic text-foreground">Kho minh chứng</span>, hoặc{' '}
<span className="font-medium not-italic text-foreground">Duyệt</span>.
</>
);
function safeFileBaseName(initiativeName: string, fallbackId: string): string {
const raw = initiativeName.trim() || fallbackId;
const cleaned = raw.replace(/[^a-zA-Z0-9_\-\u00C0-\u024F\s]+/g, '-').trim();
return cleaned || 'ho-so-sang-kien';
}
function triggerDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
type SaveAllDraftsPayload = {
report: Record<string, unknown>;
application: Record<string, unknown>;
contribution: Record<string, unknown>;
};
export type ApplicationFormDocxPreviewProps = {
draft: InitiativeDraft;
contribution: ContributionDraftShape;
className?: string;
/** Backend case id (review JSON, PDF minh chứng từ MinIO). */
caseId?: string;
onSaveAllDrafts?: (payload: SaveAllDraftsPayload) => Promise<void>;
/** Hội đồng / quản trị: thay nút Lưu / Gửi và cam kết ứng viên. */
docxStaffFooter?: ReactNode;
/**
* Ban quản trị chỉ xem: ẩn « Tải DOCX » / « Tải PDF » (Hội đồng vẫn dùng hai nút này).
* @default false
*/
hideDocxPdfExportButtons?: boolean;
};
/**
* Mẫu hồ sơ `template_application_form.docx` — nguồn dữ liệu: ba form Đơn / Báo cáo / Xác nhận đóng góp
* (cùng state với `ReviewSnapshotSections`).
*/
export function ApplicationFormDocxPreview({
draft,
contribution,
className,
caseId,
onSaveAllDrafts,
docxStaffFooter,
hideDocxPdfExportButtons = false,
}: ApplicationFormDocxPreviewProps) {
const queryClient = useQueryClient();
const { user } = useAuth();
const draftContext = useOptionalDraft();
/** Must match Postgres `Initiative.case_code` so review JSON + submit target the same row. */
const resolvedServerCaseId =
(caseId?.trim() || draftContext?.caseId?.trim() || '').trim() || undefined;
const [formDocxLoading, setFormDocxLoading] = useState(false);
const [formPdfPreviewUrl, setFormPdfPreviewUrl] = useState<string | null>(null);
const officialBieuMau = useMemo(
() =>
buildOfficialBieuMauFromDraft(
draft,
contribution as unknown as Record<string, unknown> | null,
) as unknown as Record<string, unknown>,
[draft, contribution],
);
const [isSavingDrafts, setIsSavingDrafts] = useState(false);
const [isExportingDocx, setIsExportingDocx] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHonestyConfirmed, setIsHonestyConfirmed] = useState(false);
const [browserPdfToolOpen, setBrowserPdfToolOpen] = useState(false);
const [browserPdfToolFile, setBrowserPdfToolFile] = useState<File | null>(null);
const [browserPdfToolLoading, setBrowserPdfToolLoading] = useState(false);
useEffect(() => {
const both =
Boolean(draft.application.honestyConfirmed) && Boolean(draft.report.honestyConfirmed);
setIsHonestyConfirmed(both);
}, [draft.draftId, draft.application.honestyConfirmed, draft.report.honestyConfirmed]);
useEffect(() => {
if (!browserPdfToolOpen) return;
let cancelled = false;
setBrowserPdfToolLoading(true);
setBrowserPdfToolFile(null);
void (async () => {
try {
const buf = await fetchApplicationFormDocxPreview(officialBieuMau);
if (cancelled) return;
setBrowserPdfToolFile(
new File([buf], 'mau-ho-so-xem-truoc.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}),
);
} catch (e) {
if (!cancelled) {
const msg = e instanceof Error ? e.message : 'Không tải được DOCX cho công cụ cục bộ.';
toast.error(msg);
}
} finally {
if (!cancelled) setBrowserPdfToolLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [browserPdfToolOpen, officialBieuMau]);
const a = draft.application;
const r = draft.report;
const contributionRecord = contribution as unknown as Record<string, unknown>;
const contributionDigitalSigned = Boolean(contributionRecord.digitalSignatureConfirmed);
const submitPrerequisiteGaps = useMemo(
() =>
collectApplicantSubmitToAdminPrerequisiteGaps(
draft.report,
draft.application,
contributionDigitalSigned,
),
[draft.report, draft.application, contributionDigitalSigned],
);
const persistAllDraftsAndReviewBundle = async () => {
if (!onSaveAllDrafts) {
throw new Error('Thiếu tác vụ lưu bản nháp. Vui lòng tải lại trang hoặc thử sau.');
}
await onSaveAllDrafts({
report: r as unknown as Record<string, unknown>,
application: a as unknown as Record<string, unknown>,
contribution: contributionRecord,
});
await persistReviewDocumentBundle(draft, contributionRecord, resolvedServerCaseId);
};
const handleSaveDrafts = async () => {
if (isSavingDrafts) return;
try {
setIsSavingDrafts(true);
await persistAllDraftsAndReviewBundle();
toast.success('Đã lưu toàn bộ bản nháp (3 tab) và dữ liệu xem lại lên máy chủ.');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Không thể lưu bản nháp. Vui lòng thử lại.';
toast.error(msg);
} finally {
setIsSavingDrafts(false);
}
};
const handleExportDocx = async () => {
if (isExportingDocx) return;
setIsExportingDocx(true);
try {
const buf = await fetchApplicationFormDocxPreview(officialBieuMau);
const blob = new Blob([buf], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
const initiativeName =
draft.application.initiativeName?.trim() || draft.report.initiativeName?.trim() || '';
const base = safeFileBaseName(initiativeName, draft.draftId);
triggerDownload(blob, `${base}-mau-ho-so.docx`);
toast.success('Đã tải file Word mẫu hồ sơ.');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Không tải được DOCX.';
toast.error(msg);
} finally {
setIsExportingDocx(false);
}
};
const handleSubmitToAdmin = async () => {
if (!isHonestyConfirmed || isSubmitting) return;
if (submitPrerequisiteGaps.length > 0) {
toast.error('Chưa đủ điều kiện gửi hồ sơ', {
description: formatApplicantPrerequisiteToastDescription(submitPrerequisiteGaps),
});
return;
}
const initiativeName =
draft.application.initiativeName?.trim() || draft.report.initiativeName?.trim() || '';
if (!initiativeName) {
toast.error('Vui lòng điền tên sáng kiến ở Đơn hoặc Báo cáo trước khi nộp.');
return;
}
const loadingToastId = toast.loading(
'Đang lưu bản nháp, tạo PDF và gửi… (quá trình có thể mất vài phút; vui lòng không đóng trang)',
);
setIsSubmitting(true);
try {
await persistAllDraftsAndReviewBundle();
const primaryAuthor = draft.application.authors[0];
const response = await runInitiativeSubmitWorkflow({
draft,
initiativeCaseId: resolvedServerCaseId,
contributionDraft: contributionRecord,
onSaveAllDrafts: undefined,
metadata: {
initiativeName,
authorName:
draft.report.representativeAuthor?.trim() ||
primaryAuthor?.name?.trim() ||
user?.name ||
'—',
authorEmail: draft.report.representativeEmail?.trim() || user?.email || undefined,
authorPhone: draft.report.representativePhone?.trim() || undefined,
initiativeCaseId: resolvedServerCaseId,
caseId: resolvedServerCaseId ?? draft.draftId,
topicType: draft.application.applicationField?.trim() || undefined,
},
});
saveApplicantSubmissionRecord(response, {
caseId: resolvedServerCaseId ?? draft.draftId,
initiativeName: response.name ?? initiativeName,
});
await queryClient.invalidateQueries({ queryKey: ['applications'] });
await queryClient.invalidateQueries({ queryKey: ['applications-mine'] });
await queryClient.invalidateQueries({ queryKey: ['application-detail', response.id] });
toast.success(
'Đã gửi hồ sơ PDF. Ban quản trị xem tại mục Danh Sách Sáng kiến trên bảng điều khiển.',
{ id: loadingToastId },
);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Không thể gửi hồ sơ.';
toast.error(msg, { id: loadingToastId });
} finally {
setIsSubmitting(false);
}
};
const runFormDocxPreview = useCallback(async () => {
setFormDocxLoading(true);
try {
const buf = await fetchApplicationFormOfficialPdf(officialBieuMau);
const blob = new Blob([buf], {
type: 'application/pdf',
});
const nextUrl = URL.createObjectURL(blob);
setFormPdfPreviewUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return nextUrl;
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'Không xem trước được mẫu PDF.';
toast.error(msg);
} finally {
setFormDocxLoading(false);
}
}, [officialBieuMau]);
useEffect(() => {
void runFormDocxPreview();
}, [runFormDocxPreview]);
useEffect(() => {
return () => {
if (formPdfPreviewUrl) URL.revokeObjectURL(formPdfPreviewUrl);
};
}, [formPdfPreviewUrl]);
return (
<div
className={
'rounded-lg border border-border/60 bg-muted/15 p-3 sm:p-4 ' + (className ?? '')
}
>
<section aria-labelledby="form-docx-preview-heading" className="flex flex-col h-full space-y-2">
<h3 id="form-docx-preview-heading" className="text-sm font-semibold text-foreground">
Xem trước mẫu hồ (PDF)
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{docxStaffFooter
? hideDocxPdfExportButtons
? ADMIN_READONLY_PREVIEW_EXPLANATION
: STAFF_PREVIEW_EXPLANATION
: null}
</p>
{formDocxLoading && (
<p className="text-sm text-muted-foreground flex items-center gap-2 py-1">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
Đang tạo bản xem trước
</p>
)}
<div className="h-[792pt] max-h-[90vh] overflow-hidden rounded border border-border/60 bg-muted/20">
{formPdfPreviewUrl ? (
<iframe
src={formPdfPreviewUrl}
title="Xem trước mẫu hồ sơ PDF"
className="h-full w-full"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Chưa dữ liệu xem trước PDF.
</div>
)}
</div>
</section>
{!docxStaffFooter || !hideDocxPdfExportButtons ? (
<div className="mt-4 flex flex-col h-full hidden-overflow gap-2 sm:flex-row sm:flex-wrap">
{!hideDocxPdfExportButtons ? (
<ApplicationFormOfficialPdfActions
draft={draft}
contribution={contribution}
caseId={resolvedServerCaseId}
disabled={isSubmitting}
className="flex w-full flex-col gap-2 sm:flex-row sm:w-auto sm:flex-wrap"
/>
) : null}
{!docxStaffFooter ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => void handleSaveDrafts()}
disabled={!onSaveAllDrafts || isSavingDrafts || isSubmitting}
>
{isSavingDrafts ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin inline" aria-hidden />
Đang lưu
</>
) : (
"Lưu bản nháp"
)}
</Button>
) : null}
{!hideDocxPdfExportButtons ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => void handleExportDocx()}
disabled={isExportingDocx || isSubmitting}
>
{isExportingDocx ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin inline" aria-hidden />
Đang tải DOCX
</>
) : (
"Tải DOCX"
)}
</Button>
) : null}
</div>
) : null}
{!hideDocxPdfExportButtons ? (
<details
className="mt-4 rounded-md border border-border/60 bg-muted/10 text-sm"
open={browserPdfToolOpen}
onToggle={(e) => {
const el = e.currentTarget;
setBrowserPdfToolOpen(el.open);
if (!el.open) setBrowserPdfToolFile(null);
}}
>
<summary className="cursor-pointer select-none px-3 py-2 font-medium text-foreground">
Công cụ xuất PDF cục bộ (docx-to-pdf-demo · docx-preview + html2canvas)
</summary>
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<p className="text-xs text-muted-foreground leading-relaxed">
Dùng khi cần xem từng trang raster trong trình duyệt hoặc khi API LibreOffice chưa sẵn sàng. Nút « Xem trước
PDF » / « Xuất PDF » phía trên ưu tiên bản máy chủ (đng bộ MinIO sau khi nộp).
</p>
{browserPdfToolLoading ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
Đang lấy DOCX từ API
</p>
) : null}
{browserPdfToolFile ? (
<DocxToPdfViewer file={browserPdfToolFile} hideFilePicker className="space-y-2" />
) : null}
</div>
</details>
) : null}
{docxStaffFooter ? (
<div className="mt-4 space-y-3 border-t border-border/60 pt-4">{docxStaffFooter}</div>
) : (
<div className="mt-4 space-y-3 border-t border-border/60 pt-4">
<div className="flex items-start gap-3">
<Checkbox
id="docx-preview-honesty-confirm"
checked={isHonestyConfirmed}
onCheckedChange={(checked) => {
const v = checked === true;
if (v && submitPrerequisiteGaps.length > 0) {
toast.error('Chưa đủ điều kiện xác nhận', {
description: formatApplicantPrerequisiteToastDescription(submitPrerequisiteGaps),
});
return;
}
setIsHonestyConfirmed(v);
}}
className="mt-0.5"
/>
<label
htmlFor="docx-preview-honesty-confirm"
className="italic text-sm cursor-pointer leading-snug text-muted-foreground"
>
Tôi xin cam đoan mọi thông tin nêu trong đơn trung thực, đúng sự thật hoàn toàn chịu trách nhiệm
trước pháp luật.
</label>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
onClick={() => void handleSubmitToAdmin()}
disabled={
!isHonestyConfirmed ||
isSubmitting ||
isSavingDrafts ||
!onSaveAllDrafts ||
submitPrerequisiteGaps.length > 0
}
className="shadow-lg"
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin inline" aria-hidden />
Đang gửi
</>
) : (
"Gửi tới Ban quản trị"
)}
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import {
Card,
CardContent,
buildInitiativeDraftFromReviewTabs,
type ReviewDraftTabs,
useOptionalDraft,
} from '@ump/shared';
import {
ReviewSnapshotSections,
type ContributionDraftShape,
} from '@/components/applicant/initiative-draft/ReviewSnapshotSections';
interface ReviewPanelProps {
contributionDraft?: Record<string, unknown>;
caseId?: string;
/**
* Tab JSON when there is no DraftProvider (or context failed to attach).
* Applicant dashboard should pass application + report here so the tab still renders.
*/
fallbackDraftTabs?: ReviewDraftTabs;
onSaveAllDrafts?: (payload: {
report: Record<string, unknown>;
application: Record<string, unknown>;
contribution: Record<string, unknown>;
}) => Promise<void>;
}
/** Applicant review tab: read-only field snapshot and DOCX preview (see ReviewSnapshotSections). */
export function ReviewPanel({
contributionDraft,
caseId,
fallbackDraftTabs,
onSaveAllDrafts,
}: ReviewPanelProps) {
const draftCtx = useOptionalDraft();
const fallbackDraft = useMemo(
() => buildInitiativeDraftFromReviewTabs(caseId ?? '', fallbackDraftTabs),
[caseId, fallbackDraftTabs],
);
const draft = draftCtx?.draft ?? fallbackDraft;
const c = (contributionDraft ?? {}) as ContributionDraftShape;
return (
<Card>
<CardContent className="pt-6">
<ReviewSnapshotSections
draft={draft}
contribution={c}
caseId={caseId}
onSaveAllDrafts={onSaveAllDrafts}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,267 @@
import type { ReactNode } from "react";
import {
type InitiativeDraft,
type ApplicationFormState,
type InitiativeFormState,
ensureBanCamKet,
isBanCamKetComplete,
type ContributionDraftShape,
formatDdMmYyyy,
parseContributionSubmissionDateInput,
} from '@ump/shared';
import { ApplicationFormDocxPreview } from '@/components/applicant/initiative-draft/ApplicationFormDocxPreview';
export type { ContributionDraftShape } from '@ump/shared';
const Row = ({ label, value }: { label: string; value: ReactNode }) => (
<div className="grid grid-cols-3 gap-3 py-1.5 border-b border-border/40">
<dt className="text-sm text-muted-foreground col-span-1">{label}</dt>
<dd className="text-sm col-span-2 whitespace-pre-wrap break-words">{value ?? '—'}</dd>
</div>
);
function classificationLabel(c: string | null): string {
if (c === 'technical') return 'Nhóm 1 — Giải pháp kỹ thuật / quản lý / tác nghiệp';
if (c === 'research') return 'Nhóm 2.1 — Từ nghiên cứu khoa học';
if (c === 'textbook') return 'Nhóm 2.2 — Từ sách, giáo trình';
return '—';
}
function researchKindLabel(k: string): string {
if (k === 'international') return 'Tạp chí quốc tế';
if (k === 'domestic') return 'Tạp chí trong nước';
if (k === 'poster') return 'Poster / hội nghị có phản biện';
if (k === 'poster-without-review') return 'Poster / hội nghị không phản biện';
return k || '—';
}
function formatContributionDate(input: string | Date | undefined): string {
if (!input) return '—';
const d = input instanceof Date ? input : parseContributionSubmissionDateInput(input);
if (!d || Number.isNaN(d.getTime())) return '—';
return formatDdMmYyyy(d);
}
interface ReviewSnapshotSectionsProps {
draft: InitiativeDraft;
contribution: ContributionDraftShape;
caseId?: string;
onSaveAllDrafts?: (payload: {
report: Record<string, unknown>;
application: Record<string, unknown>;
contribution: Record<string, unknown>;
}) => Promise<void>;
/** Hội đồng: thay footer ứng viên (Lưu / Gửi) bằng Từ chối / Kho minh chứng / Duyệt. */
docxStaffFooter?: ReactNode;
}
/**
* Read-only snapshot of every scalar / table field edited in Đơn, Báo cáo, and Xác nhận đóng góp
* (aligned with {@link buildDocxTemplateExportBundle} / full JSON exports).
*/
export function ReviewSnapshotSections({
draft,
contribution,
caseId,
onSaveAllDrafts,
docxStaffFooter,
}: ReviewSnapshotSectionsProps) {
const a = draft.application as unknown as ApplicationFormState;
const r = draft.report as unknown as InitiativeFormState;
const c = contribution;
const evidenceNames = [a.textbookEvidenceFile, a.researchEvidenceFile, a.technicalEvidenceFile]
.filter(Boolean)
.map((h) => h!.name)
.join(', ');
return (
<div className="space-y-8">
<ApplicationFormDocxPreview
draft={draft}
contribution={c}
caseId={caseId}
onSaveAllDrafts={docxStaffFooter ? undefined : onSaveAllDrafts}
docxStaffFooter={docxStaffFooter}
/>
{/* <section>
<h3 className="font-bold mb-2">Đơn đề nghị công nhận sáng kiến</h3>
<dl>
<Row label="Đơn vị" value={a.unitName} />
<Row
label="Tác giả (danh sách)"
value={
<ul className="list-disc pl-5 space-y-2">
{a.authors.map((x) => (
<li key={x.id}>
<span className="font-medium">{x.name || '—'}</span>
<br />
<span className="text-muted-foreground text-xs">
Ngày sinh: {x.dob || '—'} · Nơi CT: {x.workplace || '—'} · Chức danh: {x.title || '—'} ·
Trình độ: {x.qualification || '—'} · %: {x.contributionPercent}
</span>
</li>
))}
</ul>
}
/>
<Row label="Tên sáng kiến" value={a.initiativeName} />
<Row label="Chủ đầu tư" value={a.investorName} />
<Row label="Lĩnh vực áp dụng" value={a.applicationField} />
<Row label="Ngày áp dụng lần đầu" value={a.firstApplyDate} />
<Row label="Phân loại" value={classificationLabel(a.initiativeClassification)} />
<Row label="Loại minh chứng (nghiên cứu)" value={researchKindLabel(a.researchEvidenceKind)} />
<Row
label="Bản cam kết tác giả (bài báo QT)"
value={(() => {
const bck = ensureBanCamKet(a.banCamKet);
if (isBanCamKetComplete(bck)) {
return (
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>
Ngày ký: {bck.ngay_ky.ngay}/{bck.ngay_ky.thang}/{bck.ngay_ky.nam}
</li>
<li>Tác giả đăng ký: {bck.tac_gia_dang_ky}</li>
<li>CCCD/HC: {bck.cccd}</li>
<li>Đơn vị: {bck.don_vi}</li>
<li>Tên bài báo: {bck.ten_bai_bao}</li>
<li>Năm xét: {bck.nam_xet}</li>
<li>
Vai trò:{' '}
{bck.vai_tro.tac_gia_chinh
? 'Tác giả chính'
: bck.vai_tro.dong_tac_gia
? 'Đồng tác giả'
: '—'}
</li>
<li>Người cam kết: {bck.nguoi_cam_ket}</li>
</ul>
);
}
return a.internationalJournalDeclaration?.trim() || '—';
})()}
/>
<Row label="Minh chứng PDF (tên file)" value={evidenceNames || '—'} />
<Row label="Nội dung của sáng kiến (§4)" value={a.contentSummary} />
<Row label="Thông tin bảo mật" value={a.confidentialInfo} />
<Row label="Điều kiện áp dụng (Đơn)" value={a.conditions} />
<Row label="Đánh giá lợi ích (tác giả)" value={a.authorEvaluation} />
<Row label="Đánh giá (đơn vị thử)" value={a.trialEvaluation} />
<Row
label="Người hỗ trợ áp dụng thử"
value={
a.supportStaff.length === 0 ? (
'—'
) : (
<ul className="list-disc pl-5 space-y-1">
{a.supportStaff.map((x) => (
<li key={x.id}>
{x.name} — {x.dob} · {x.workplace} · {x.title} · {x.qualification}: {x.supportContent}
</li>
))}
</ul>
)
}
/>
<Row
label="Ngày ký đơn (ngày / tháng / năm)"
value={`${a.submissionDay} / ${a.submissionMonth} / ${a.submissionYear}`}
/>
</dl>
</section> */}
{/* <section>
<h3 className="font-bold mb-2">Báo cáo mô tả sáng kiến</h3>
<dl>
<Row label="Mở đầu (§1)" value={r.introduction} />
<Row label="Tên sáng kiến (báo cáo)" value={r.initiativeName} />
<Row label="Tác giả đại diện" value={r.representativeAuthor} />
<Row label="Điện thoại" value={r.representativePhone} />
<Row label="Email" value={r.representativeEmail} />
<Row label="Lĩnh vực áp dụng" value={r.applicationField} />
<Row label="Hiện trạng / giải pháp đã biết (§4.1)" value={r.currentStatus} />
<Row label="Mục đích (§4.2)" value={r.purpose} />
<Row label="Nội dung giải pháp" value={r.solutionContent} />
<Row label="Các bước thực hiện" value={r.implementationSteps} />
<Row label="Đơn vị áp dụng lần đầu" value={r.firstAppliedUnit} />
<Row label="Điều kiện (báo cáo)" value={r.conditions} />
<Row label="Kết quả thu được" value={r.achievedResult} />
<Row
label="Đơn vị / cá nhân áp dụng thử"
value={
r.trialUnits.length === 0 ? (
'—'
) : (
<ul className="list-disc pl-5">
{r.trialUnits.map((u) => (
<li key={u.id}>
{u.name} — {u.address} · {u.field}
</li>
))}
</ul>
)
}
/>
<Row label="Tính mới" value={r.novelty} />
<Row label="Hiệu quả — Kinh tế" value={r.effectiveness.economic} />
<Row label="Hiệu quả — Xã hội / nhận thức" value={r.effectiveness.social} />
<Row label="Hiệu quả — Giảng dạy / công việc" value={r.effectiveness.teaching} />
<Row label="Hiệu quả — Năng suất" value={r.effectiveness.productivity} />
<Row label="Hiệu quả — Chất lượng" value={r.effectiveness.quality} />
<Row label="Hiệu quả — Môi trường" value={r.effectiveness.environment} />
<Row label="Hiệu quả — An toàn" value={r.effectiveness.safety} />
<Row label="Thông tin bảo mật (báo cáo)" value={r.confidentialInfo} />
<Row label="Ngày nộp (báo cáo)" value={r.submissionDate} />
<Row label="Tác giả ký (báo cáo)" value={r.authorName} />
</dl>
</section> */}
{/* <section>
<h3 className="font-bold mb-2">Bản xác nhận tỷ lệ đóng góp</h3>
<dl>
<Row label="Tên sáng kiến" value={c.initiativeName ?? '—'} />
<Row label="Tác giả chính / đại diện" value={c.mainAuthor ?? '—'} />
<Row label="Chức vụ / đơn vị công tác" value={c.position ?? '—'} />
<Row
label="Tỷ lệ đóng góp đại diện (%)"
value={typeof c.representativePercent === 'number' ? `${c.representativePercent}%` : '—'}
/>
<Row label="Ngày nộp" value={formatContributionDate(c.submissionDate)} />
<Row
label="Xác nhận chữ ký số"
value={c.digitalSignatureConfirmed ? 'Đã xác nhận' : 'Chưa xác nhận'}
/>
<Row
label="Danh sách tỷ lệ (tab xác nhận)"
value={
c.participants && c.participants.length > 0 ? (
<ul className="list-disc pl-5">
{c.participants.map((p) => (
<li key={p.id}>
{p.fullName || '—'} — {p.workUnit || '—'} ({p.contributionPercent ?? 0}%)
</li>
))}
</ul>
) : a.authors?.length ? (
<>
<p className="text-xs text-muted-foreground mb-1 italic">
Chưa có dòng trong bản nháp xác nhận — hiển thị tác giả từ Đơn:
</p>
<ul className="list-disc pl-5">
{a.authors.map((x) => (
<li key={x.id}>
{x.name} — {x.workplace} ({x.contributionPercent}%)
</li>
))}
</ul>
</>
) : (
'—'
)
}
/>
</dl>
</section> */}
</div>
);
}
@@ -0,0 +1,11 @@
/** Re-export PDF helpers from the canonical `applicant/pdfExport` module (slice 4a — @react-pdf engine). */
export {
exportDraftPdf,
exportApplicationFormPdf,
renderApplicationPdfBlob,
renderFullDraftPdfBlob,
ApplicationPdfDocument,
FullDraftPdfDocument,
ApplicationPdfPage,
ReportPdfPage,
} from '@/components/applicant/pdfExport';
@@ -0,0 +1,47 @@
import { Construction } from "lucide-react";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@ump/shared";
/**
* Props the applicant workspace tabs are called with by `ApplicantRegistrationDashboard`.
*
* The walking-skeleton stubs accept the full prop surface (kept permissive via the
* index signature) and only wire `onNext`, which is what drives the report-gate flow.
* Slices 3/4 replace each stub file with the real ported form at the same path.
*/
export type WorkspacePlaceholderProps = { onNext?: () => void } & Record<string, unknown>;
/** Shared "đang được di chuyển" card rendered inside each not-yet-migrated workspace tab. */
export function WorkspaceTabPlaceholder({
title,
slice,
onNext,
}: {
title: string;
slice: string;
onNext?: () => void;
}) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Construction className="h-5 w-5 text-muted-foreground" />
</div>
<CardTitle className="font-serif text-lg">{title}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Biểu mẫu này đang đưc di chuyển sang ng dụng mới ({slice}). Cấu trúc workspace, khóa
tab theo bước vòng đi bản nháp đã hoạt đng.
</p>
{onNext ? (
<Button type="button" variant="outline" onClick={onNext}>
Tiếp theo
</Button>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,720 @@
// PDF export: đơn đề nghị (Noto Serif) + báo cáo mô tả trong một file khi xuất từ bản nháp.
// Dùng @react-pdf/renderer; tải xuống qua blob URL (không cần file-saver).
import {
Document,
Font,
Page,
StyleSheet,
Text,
View,
pdf,
} from '@react-pdf/renderer';
import type {
ApplicationFormState,
Author,
InitiativeDraft,
InitiativeFormState,
SupportStaff,
} from '@ump/shared';
import {
ensureBanCamKet,
ensureReferenceMaterialHonesty,
ensureResearchDomesticHonesty,
isBanCamKetComplete,
isReferenceMaterialHonestyComplete,
isResearchDomesticHonestyComplete,
getFile,
PDF_RENDER_DEADLINE_MS,
withPromiseTimeout,
displayDobStoredAsDdMm,
formatVnDateFieldForDisplay,
} from '@ump/shared';
// ---------------------------------------------------------------------
// Font registration — Vietnamese support
//
// @react-pdf/renderer's default font (Helvetica) does NOT include
// Vietnamese diacritics. We register Noto Serif (Times New Roman-ish,
// conventional for Vietnamese administrative documents) from Google Fonts.
//
// If your deployment blocks gstatic.com, self-host these TTFs and update
// the `src` URLs below.
// ---------------------------------------------------------------------
Font.register({
family: 'NotoSerif',
fonts: [
{
src: 'https://raw.githubusercontent.com/google/fonts/main/ofl/notoserif/NotoSerif%5Bwdth,wght%5D.ttf',
fontWeight: 'normal',
},
{
src: 'https://raw.githubusercontent.com/google/fonts/main/ofl/notoserif/NotoSerif%5Bwdth,wght%5D.ttf',
fontWeight: 'bold',
},
{
src: 'https://raw.githubusercontent.com/google/fonts/main/ofl/notoserif/NotoSerif-Italic%5Bwdth,wght%5D.ttf',
fontWeight: 'normal',
fontStyle: 'italic',
},
],
});
// Prevent aggressive word-break hyphenation for Vietnamese syllables
Font.registerHyphenationCallback((word) => [word]);
// ---------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------
const styles = StyleSheet.create({
page: {
fontFamily: 'NotoSerif',
fontSize: 11,
paddingTop: 56, // 20mm
paddingBottom: 56,
paddingLeft: 85, // 30mm
paddingRight: 56,
lineHeight: 1.4,
},
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 10 },
headerCol: { width: '48%', textAlign: 'center' },
headerLine: { fontWeight: 'bold', fontSize: 10 },
title: {
textAlign: 'center',
fontSize: 15,
fontWeight: 'bold',
marginTop: 10,
marginBottom: 4,
},
subtitle: {
textAlign: 'center',
fontStyle: 'italic',
fontSize: 11,
marginBottom: 14,
},
sectionTitle: { fontWeight: 'bold', marginTop: 10, marginBottom: 4 },
body: { textAlign: 'justify' },
kv: { marginBottom: 2 },
kvLabel: { fontWeight: 'bold' },
italic: { fontStyle: 'italic' },
// tables
table: { width: '100%', borderStyle: 'solid', borderWidth: 1, borderColor: '#000', marginBottom: 6 },
tRow: { flexDirection: 'row' },
tHeader: { backgroundColor: '#f0f0f0' },
tCell: {
padding: 3,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: '#000',
fontSize: 9,
},
tCellLast: { padding: 3, borderBottomWidth: 1, borderColor: '#000', fontSize: 9 },
tCellBold: { fontWeight: 'bold', textAlign: 'center' },
center: { textAlign: 'center' },
// signatures
footer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 18 },
footerCol: { width: '48%', textAlign: 'center' },
signSpace: { height: 80 },
commitment: { textAlign: 'center', fontStyle: 'italic', marginTop: 14, marginBottom: 10 },
pageFooter: {
position: 'absolute',
bottom: 20,
left: 56,
right: 56,
fontSize: 8,
color: '#666',
textAlign: 'center',
},
});
// ---------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------
const box = (checked: boolean) => (checked ? '☒' : '☐');
function FormHeader() {
return (
<>
<View style={styles.header} fixed>
<View style={styles.headerCol}>
<Text style={styles.headerLine}>ĐI HỌC Y DƯỢC TP.HCM</Text>
<Text style={styles.headerLine}>HỘI ĐNG SÁNG KIẾN</Text>
</View>
<View style={styles.headerCol}>
<Text style={styles.headerLine}>CỘNG HÒA HỘI CHỦ NGHĨA VIỆT NAM</Text>
<Text style={styles.headerLine}>Đc lập - Tự do - Hạnh phúc</Text>
</View>
</View>
<Text style={styles.title}>ĐƠN Đ NGHỊ CÔNG NHẬN SÁNG KIẾN</Text>
<Text style={styles.subtitle}>
Kính gửi: Hội đng sáng kiến Đi học Y Dược TP. Hồ Chí Minh
</Text>
</>
);
}
interface Col {
key: string;
label: string;
width: string; // percentage, e.g. '22%'
align?: 'left' | 'center' | 'right';
}
function DataTable<T extends { id: number }>({
cols,
rows,
render,
emptyText,
}: {
cols: Col[];
rows: T[];
render: (row: T, idx: number) => (string | number)[];
emptyText?: string;
}) {
return (
<View style={styles.table}>
{/* header */}
<View style={[styles.tRow, styles.tHeader]}>
{cols.map((c, i) => (
<Text
key={c.key}
style={[
i === cols.length - 1 ? styles.tCellLast : styles.tCell,
styles.tCellBold,
{ width: c.width },
]}
>
{c.label}
</Text>
))}
</View>
{/* body */}
{rows.length === 0 && emptyText ? (
<View style={styles.tRow}>
<Text style={[styles.tCellLast, styles.italic, styles.center, { width: '100%' }]}>
{emptyText}
</Text>
</View>
) : (
rows.map((row, idx) => {
const values = render(row, idx);
return (
<View key={row.id} style={styles.tRow}>
{cols.map((c, i) => (
<Text
key={c.key}
style={[
i === cols.length - 1 ? styles.tCellLast : styles.tCell,
{ width: c.width, textAlign: c.align ?? 'left' },
]}
>
{String(values[i] ?? '')}
</Text>
))}
</View>
);
})
)}
</View>
);
}
function AuthorsTable({ authors }: { authors: Author[] }) {
const cols: Col[] = [
{ key: 'n', label: 'STT', width: '6%', align: 'center' },
{ key: 'name', label: 'Họ và tên', width: '22%' },
{ key: 'dob', label: 'Ngày sinh', width: '12%', align: 'center' },
{ key: 'wp', label: 'Nơi công tác', width: '20%' },
{ key: 'ti', label: 'Chức danh', width: '14%' },
{ key: 'q', label: 'Trình độ CM', width: '14%' },
{ key: 'pc', label: '% Đóng góp', width: '12%', align: 'center' },
];
return (
<DataTable
cols={cols}
rows={authors}
render={(a, i) => [
i + 1,
a.name,
displayDobStoredAsDdMm(a.dob),
a.workplace,
a.title,
a.qualification,
a.contributionPercent ? `${a.contributionPercent}%` : '',
]}
/>
);
}
function SupportStaffTable({ staff }: { staff: SupportStaff[] }) {
const cols: Col[] = [
{ key: 'n', label: 'STT', width: '5%', align: 'center' },
{ key: 'name', label: 'Họ và tên', width: '18%' },
{ key: 'dob', label: 'Ngày sinh', width: '11%', align: 'center' },
{ key: 'wp', label: 'Nơi công tác', width: '17%' },
{ key: 'ti', label: 'Chức danh', width: '12%' },
{ key: 'q', label: 'Trình độ CM', width: '12%' },
{ key: 'sc', label: 'Nội dung hỗ trợ', width: '25%' },
];
return (
<DataTable
cols={cols}
rows={staff}
emptyText="Không có người hỗ trợ"
render={(s, i) => [
i + 1,
s.name,
displayDobStoredAsDdMm(s.dob),
s.workplace,
s.title,
s.qualification,
s.supportContent,
]}
/>
);
}
function Classification({ f }: { f: ApplicationFormState }) {
const attached: string[] = [];
if (f.technicalEvidenceFile)
attached.push(`Nhóm 1: ${f.technicalEvidenceFile.name}`);
if (f.researchEvidenceFile)
attached.push(`Nhóm 2.1: ${f.researchEvidenceFile.name}`);
if (f.textbookEvidenceFile)
attached.push(`Nhóm 2.2: ${f.textbookEvidenceFile.name}`);
return (
<View>
<Text style={styles.sectionTitle}>3. Phân loại sáng kiến:</Text>
<Text>
{box(f.initiativeClassification === 'technical')} Nhóm 1 Giải pháp kỹ thuật,
quản , tác nghiệp, ng dụng tiến bộ kỹ thuật áp dụng cho Đi học Y Dược TP.HCM
</Text>
<Text>
{box(f.initiativeClassification === 'research')} Nhóm 2.1 Sáng kiến cải tiến
kỹ thuật từ nghiên cứu khoa học (đã đăng tạp chí/hội nghị)
</Text>
<Text>
{box(f.initiativeClassification === 'textbook')} Nhóm 2.2 Sáng kiến cải tiến
kỹ thuật từ sách, giáo trình, tài liệu tham khảo
</Text>
{f.initiativeClassification === 'research' && (
<View style={{ marginTop: 4 }}>
<Text style={[styles.italic, { fontWeight: 'bold', marginBottom: 2 }]}>
Loại minh chứng nghiên cứu:
</Text>
<Text>{box(f.researchEvidenceKind === 'international')} 2.1.1 Bài báo tạp chí quốc tế</Text>
<Text>{box(f.researchEvidenceKind === 'domestic')} 2.1.2 Bài báo tạp chí trong nước</Text>
<Text>{box(f.researchEvidenceKind === 'poster')} 2.1.3 Poster / báo cáo hội nghị phản biện</Text>
<Text>
{box(f.researchEvidenceKind === 'poster-without-review')} 2.1.4 Poster / báo cáo hội nghị không phản biện
</Text>
{f.researchEvidenceKind === 'international' &&
(() => {
const bck = ensureBanCamKet(f.banCamKet);
const legacy = f.internationalJournalDeclaration.trim();
if (!isBanCamKetComplete(bck) && !legacy) return null;
return (
<>
<Text style={[styles.italic, { fontWeight: 'bold', marginTop: 4 }]}>
Bản cam kết của tác giả (tạp chí quốc tế):
</Text>
{isBanCamKetComplete(bck) ? (
<>
<Text style={styles.body}>
Ngày : {bck.ngay_ky.ngay}/{bck.ngay_ky.thang}/{bck.ngay_ky.nam}
</Text>
<Text style={styles.body}>Tác giả đăng : {bck.tac_gia_dang_ky}</Text>
<Text style={styles.body}>CCCD/HC: {bck.cccd}</Text>
<Text style={styles.body}>Đơn vị: {bck.don_vi}</Text>
<Text style={styles.body}>Tên bài báo: {bck.ten_bai_bao}</Text>
<Text style={styles.body}>Năm xét: {bck.nam_xet}</Text>
<Text style={styles.body}>
Vai trò:{' '}
{bck.vai_tro.tac_gia_chinh
? 'Tác giả chính'
: bck.vai_tro.dong_tac_gia
? 'Đồng tác giả'
: '—'}
</Text>
<Text style={styles.body}>Người cam kết: {bck.nguoi_cam_ket}</Text>
</>
) : (
<Text style={styles.body}>{f.internationalJournalDeclaration}</Text>
)}
</>
);
})()}
{f.researchEvidenceKind === 'domestic' &&
(() => {
const dj = ensureResearchDomesticHonesty(f.researchDomesticHonesty);
if (!isResearchDomesticHonestyComplete(dj)) return null;
return (
<>
<Text style={[styles.italic, { fontWeight: 'bold', marginTop: 4 }]}>
Biểu xác nhận bài báo trong nước (2.1.2):
</Text>
<Text style={styles.body}>
Ngày : {dj.ngay_ky.ngay}/{dj.ngay_ky.thang}/{dj.ngay_ky.nam}
</Text>
{dj.tieu_de_phu.trim() ? (
<Text style={styles.body}>Tiêu đ phụ: {dj.tieu_de_phu.trim()}</Text>
) : null}
<Text style={styles.body}>Tác giả đăng : {dj.tac_gia_dang_ky}</Text>
<Text style={styles.body}>CCCD/HC: {dj.cccd}</Text>
<Text style={styles.body}>Đơn vị: {dj.don_vi}</Text>
<Text style={styles.body}>Tên bài báo: {dj.ten_bai_bao}</Text>
<Text style={styles.body}>Năm xét công nhận sáng kiến: {dj.nam_xet}</Text>
<Text style={styles.body}>Người cam kết: {dj.nguoi_cam_ket}</Text>
</>
);
})()}
</View>
)}
{f.initiativeClassification === 'textbook' && (
<View style={{ marginTop: 4 }}>
<Text style={[styles.italic, { fontWeight: 'bold', marginBottom: 2 }]}>
Loại minh chứng sách/giáo trình:
</Text>
<Text>{box(f.textbookEvidenceKind === 'book')} Xuất sắc Sách, giáo trình</Text>
<Text>{box(f.textbookEvidenceKind === 'reference')} 2.2.2 Tài liệu tham khảo</Text>
{f.textbookEvidenceKind === 'book' &&
(() => {
const bck = ensureBanCamKet(f.banCamKet);
if (!isBanCamKetComplete(bck)) return null;
return (
<>
<Text style={[styles.italic, { fontWeight: 'bold', marginTop: 4 }]}>
Bản cam kết của tác giả (Xuất sắc Sách, giáo trình):
</Text>
<Text style={styles.body}>
Ngày : {bck.ngay_ky.ngay}/{bck.ngay_ky.thang}/{bck.ngay_ky.nam}
</Text>
<Text style={styles.body}>Tác giả đăng : {bck.tac_gia_dang_ky}</Text>
<Text style={styles.body}>CCCD/HC: {bck.cccd}</Text>
<Text style={styles.body}>Đơn vị: {bck.don_vi}</Text>
<Text style={styles.body}>Tên bài báo/sách (biểu mẫu): {bck.ten_bai_bao}</Text>
<Text style={styles.body}>Năm xét: {bck.nam_xet}</Text>
<Text style={styles.body}>Người cam kết: {bck.nguoi_cam_ket}</Text>
</>
);
})()}
{f.textbookEvidenceKind === 'reference' &&
(() => {
const rm = ensureReferenceMaterialHonesty(f.referenceMaterialHonesty);
if (!isReferenceMaterialHonestyComplete(rm)) return null;
return (
<>
<Text style={[styles.italic, { fontWeight: 'bold', marginTop: 4 }]}>
Biểu xác nhận tài liệu tham khảo (2.2.2):
</Text>
<Text style={styles.body}>
Ngày : {rm.ngay_ky.ngay}/{rm.ngay_ky.thang}/{rm.ngay_ky.nam}
</Text>
<Text style={styles.body}>Tác giả đăng : {rm.tac_gia_dang_ky}</Text>
<Text style={styles.body}>CCCD/HC: {rm.cccd}</Text>
<Text style={styles.body}>Đơn vị: {rm.don_vi}</Text>
<Text style={styles.body}>Tên tài liệu: {rm.ten_tai_lieu}</Text>
<Text style={styles.body}>Năm xét: {rm.nam_xet}</Text>
<Text style={styles.body}>Người cam kết: {rm.nguoi_cam_ket}</Text>
</>
);
})()}
</View>
)}
{attached.length > 0 && (
<View style={{ marginTop: 4 }}>
<Text style={[styles.italic, { fontWeight: 'bold' }]}>
File minh chứng đính kèm:
</Text>
{attached.map((line) => (
<Text key={line}> {line}</Text>
))}
</View>
)}
</View>
);
}
function KV({ label, value }: { label: string; value: string }) {
return (
<Text style={styles.kv}>
<Text style={styles.kvLabel}>{label}: </Text>
{value || '…'}
</Text>
);
}
function SignatureBlock({ f }: { f: ApplicationFormState }) {
const dateLine =
f.submissionDay || f.submissionMonth || f.submissionYear
? `TP. Hồ Chí Minh, ngày ${f.submissionDay || '…'} tháng ${f.submissionMonth || '…'} năm ${f.submissionYear || '…'}`
: 'TP. Hồ Chí Minh, ngày … tháng … năm ……';
return (
<View style={styles.footer}>
<View style={styles.footerCol}>
<Text style={{ fontWeight: 'bold' }}>XÁC NHẬN CỦA LÃNH ĐO ĐƠN VỊ</Text>
<Text style={styles.italic}>( đóng dấu)</Text>
<View style={styles.signSpace} />
</View>
<View style={styles.footerCol}>
<Text style={styles.italic}>{dateLine}</Text>
<Text style={{ fontWeight: 'bold', marginTop: 2 }}>
TÁC GIẢ CHÍNH / ĐI DIỆN NHÓM TÁC GIẢ SÁNG KIẾN
</Text>
<Text style={styles.italic}>(chữ ghi họ tên)</Text>
<View style={styles.signSpace} />
</View>
</View>
);
}
// ---------------------------------------------------------------------
// Main document
// ---------------------------------------------------------------------
function ApplicationPdfPage({ data, draftId }: { data: ApplicationFormState; draftId?: string }) {
return (
<Page size="A4" style={styles.page}>
<FormHeader />
<Text style={styles.sectionTitle}>1. Tên tôi (chúng tôi) :</Text>
<AuthorsTable authors={data.authors} />
<Text style={styles.sectionTitle}>2. Thông tin sáng kiến</Text>
<KV label="Tên đơn vị" value={data.unitName} />
<KV label="Tên sáng kiến đề nghị công nhận" value={data.initiativeName} />
<KV label="Chủ đầu tư tạo ra sáng kiến (nếu khác tác giả)" value={data.investorName} />
<KV label="Lĩnh vực áp dụng" value={data.applicationField} />
<KV label="Ngày sáng kiến được áp dụng lần đầu" value={data.firstApplyDate} />
<Classification f={data} />
<Text style={styles.sectionTitle}>4. Nội dung của sáng kiến:</Text>
<Text style={styles.italic}>
( tả ngắn gọn, đy đ các bước thực hiện, kết quả, hiệu quả thử nghiệm)
</Text>
<Text style={styles.body}>{data.contentSummary || '…'}</Text>
<Text style={styles.sectionTitle}>Thông tin cần bảo mật (nếu ):</Text>
<Text style={styles.body}>{data.confidentialInfo || '…'}</Text>
<Text style={styles.sectionTitle}>Các điều kiện cần thiết đ áp dụng:</Text>
<Text style={styles.body}>{data.conditions || '…'}</Text>
<Text style={styles.sectionTitle}>5. Đánh giá lợi ích (theo ý kiến tác giả):</Text>
<Text style={styles.body}>{data.authorEvaluation || '…'}</Text>
<Text style={styles.sectionTitle}>6. Đánh giá lợi ích (theo đơn vị áp dụng thử):</Text>
<Text style={styles.body}>{data.trialEvaluation || '…'}</Text>
<Text style={styles.sectionTitle}>7. Danh sách người tham gia áp dụng thử / lần đu:</Text>
<SupportStaffTable staff={data.supportStaff} />
<Text style={styles.commitment}>
Tôi xin cam đoan mọi thông tin nêu trong đơn trung thực, đúng sự thật hoàn toàn chịu
trách nhiệm trước pháp luật.
</Text>
<SignatureBlock f={data} />
<Text
style={styles.pageFooter}
render={({ pageNumber, totalPages }) =>
draftId
? `Trang ${pageNumber} / ${totalPages}${draftId}`
: `Trang ${pageNumber} / ${totalPages}`
}
fixed
/>
</Page>
);
}
function ReportPdfPage({ report, draftId }: { report: InitiativeFormState; draftId: string }) {
return (
<Page size="A4" style={styles.page}>
<Text style={styles.title}>BÁO CÁO TẢ SÁNG KIẾN</Text>
<Text style={styles.subtitle}>Kính gửi: Hội đng sáng kiến Đi học Y Dược TP. Hồ Chí Minh</Text>
<KV label="Tên sáng kiến" value={report.initiativeName} />
<KV label="Tác giả chính / Đại diện nhóm tác giả sáng kiến" value={report.representativeAuthor} />
<KV label="Điện thoại" value={report.representativePhone} />
<KV label="Email" value={report.representativeEmail} />
<KV label="Lĩnh vực áp dụng" value={report.applicationField} />
<Text style={styles.sectionTitle}>Mở đu</Text>
<Text style={styles.body}>{report.introduction || '…'}</Text>
<Text style={styles.sectionTitle}>Hiện trạng</Text>
<Text style={styles.body}>{report.currentStatus || '…'}</Text>
<Text style={styles.sectionTitle}>Mục đích / nội dung giải pháp</Text>
<Text style={styles.body}>{report.purpose || '…'}</Text>
<Text style={styles.body}>{report.solutionContent || '…'}</Text>
<Text style={styles.sectionTitle}>Các bước thực hiện</Text>
<Text style={styles.body}>{report.implementationSteps || '…'}</Text>
<KV label="Đơn vị áp dụng lần đầu" value={report.firstAppliedUnit} />
<KV label="Kết quả thu được" value={report.achievedResult} />
<KV label="Điều kiện áp dụng" value={report.conditions} />
<Text style={styles.sectionTitle}>Đơn vị / nhân áp dụng thử</Text>
{report.trialUnits.length === 0 ? (
<Text style={styles.body}></Text>
) : (
report.trialUnits.map((u, i) => (
<KV
key={u.id}
label={`Đơn vị ${i + 1}`}
value={`${u.name}${u.address} (${u.field})`}
/>
))
)}
<Text style={styles.sectionTitle}>Tính mới</Text>
<Text style={styles.body}>{report.novelty || '…'}</Text>
<Text style={styles.sectionTitle}>Hiệu quả</Text>
<KV label="Kinh tế" value={report.effectiveness.economic} />
<KV label="Xã hội / nhận thức" value={report.effectiveness.social} />
<KV label="Giảng dạy / công việc" value={report.effectiveness.teaching} />
<KV label="Năng suất" value={report.effectiveness.productivity} />
<KV label="Chất lượng" value={report.effectiveness.quality} />
<KV label="Môi trường" value={report.effectiveness.environment} />
<KV label="An toàn" value={report.effectiveness.safety} />
<Text style={styles.sectionTitle}>Thông tin bảo mật (nếu )</Text>
<Text style={styles.body}>{report.confidentialInfo || '…'}</Text>
<KV label="Ngày nộp (báo cáo)" value={formatVnDateFieldForDisplay(report.submissionDate)} />
<KV label="Họ tên (chữ ký và ghi rõ họ tên)" value={report.authorName} />
<Text
style={styles.pageFooter}
render={({ pageNumber, totalPages }) => `Trang ${pageNumber} / ${totalPages}${draftId}`}
fixed
/>
</Page>
);
}
function ApplicationPdfDocument({ data }: { data: ApplicationFormState }) {
return (
<Document title="Đơn đề nghị công nhận sáng kiến" author="Initiative Application Form">
<ApplicationPdfPage data={data} />
</Document>
);
}
function FullDraftPdfDocument({
draft,
application,
}: {
draft: InitiativeDraft;
application: ApplicationFormState;
}) {
return (
<Document title={`Hồ sơ ${draft.draftId}`} author="Initiative draft">
<ApplicationPdfPage data={application} draftId={draft.draftId} />
<ReportPdfPage report={draft.report} draftId={draft.draftId} />
</Document>
);
}
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
const DEFAULT_FILENAME = 'Don-de-nghi-cong-nhan-sang-kien.pdf';
function triggerDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
function safeFileName(base: string) {
const cleaned = base.replace(/[^a-zA-Z0-9_\-\u00C0-\u024F\s]+/g, '-').trim();
return cleaned || 'ho-so-sang-kien';
}
async function hydrateApplicationFromDraft(
draft: InitiativeDraft,
initiativeCaseId?: string | null,
): Promise<ApplicationFormState> {
const a = draft.application;
const cid = initiativeCaseId?.trim() || null;
const [t, r, tech] = await Promise.all([
getFile(a.textbookEvidenceFile, { caseId: cid, serverKind: 'textbook' }),
getFile(a.researchEvidenceFile, { caseId: cid, serverKind: 'research' }),
getFile(a.technicalEvidenceFile, { caseId: cid, serverKind: 'technical' }),
]);
return {
...a,
textbookEvidenceFile: t,
researchEvidenceFile: r,
technicalEvidenceFile: tech,
};
}
/**
* Đơn đề nghị + báo cáo mô tả trong một file PDF (đọc minh chứng từ IndexedDB hoặc MinIO khi có `initiativeCaseId`).
*/
export async function exportDraftPdf(
draft: InitiativeDraft,
initiativeCaseId?: string | null,
): Promise<void> {
const application = await hydrateApplicationFromDraft(draft, initiativeCaseId);
const blob = await pdf(<FullDraftPdfDocument draft={draft} application={application} />).toBlob();
const name = `${safeFileName(
draft.report.initiativeName || draft.application.initiativeName || draft.draftId,
)}.pdf`;
triggerDownload(blob, name);
}
/**
* Chỉ đơn đề nghị (một file, một hoặc nhiều trang tùy độ dài nội dung).
*/
export async function exportApplicationFormPdf(
data: ApplicationFormState,
fileName: string = DEFAULT_FILENAME,
): Promise<void> {
const blob = await pdf(<ApplicationPdfDocument data={data} />).toBlob();
triggerDownload(blob, fileName);
}
/** Blob đơn đề nghị (upload / iframe preview). */
export async function renderApplicationPdfBlob(data: ApplicationFormState): Promise<Blob> {
return pdf(<ApplicationPdfDocument data={data} />).toBlob();
}
/** Blob đầy đủ đơn + báo cáo. */
export async function renderFullDraftPdfBlob(
draft: InitiativeDraft,
initiativeCaseId?: string | null,
): Promise<Blob> {
const application = await hydrateApplicationFromDraft(draft, initiativeCaseId);
return withPromiseTimeout(
pdf(<FullDraftPdfDocument draft={draft} application={application} />).toBlob(),
PDF_RENDER_DEADLINE_MS,
`Tạo file PDF quá lâu (qua ${Math.round(PDF_RENDER_DEADLINE_MS / 1000)} giây). Kiểm tra kết nối mạng hoặc thử đóng tab khác rồi gửi lại.`,
);
}
export { ApplicationPdfDocument, FullDraftPdfDocument, ApplicationPdfPage, ReportPdfPage };
@@ -0,0 +1,80 @@
import { env } from '@ump/shared';
import { getAccessToken } from '@ump/shared';
import { fetchWithTimeout, isAbortError, FETCH_SUBMIT_DEADLINE_MS } from '@ump/shared';
export interface SubmitInitiativeMetadata {
initiativeName: string;
authorName: string;
authorEmail?: string;
authorPhone?: string;
/** Postgres `Initiative.case_code` (CASE-…) — must match application-drafts autosave. */
initiativeCaseId?: string;
/** Deprecated: was client `draft.draftId` (DRAFT-…); keep only for backward compatibility. */
caseId?: string;
topicType?: string;
}
export interface SubmitInitiativeResponse {
id: string;
submittedDate: string;
publicUrl: string;
name?: string;
}
/**
* Upload full PDF hồ sơ (đơn + báo cáo) to the API; admin « Danh Sách Sáng kiến » reads from GET /api/applications.
*/
export async function submitInitiativePdf(
blob: Blob,
fileName: string,
metadata: SubmitInitiativeMetadata,
): Promise<SubmitInitiativeResponse> {
const fd = new FormData();
fd.append('file', blob, fileName);
fd.append('metadata', JSON.stringify(metadata));
const token = getAccessToken();
let res: Response;
try {
res = await fetchWithTimeout(
`${env.API_URL}/api/applications/submit`,
{
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: fd,
credentials: 'include',
},
FETCH_SUBMIT_DEADLINE_MS,
);
} catch (err) {
if (isAbortError(err)) {
throw new Error(
`Gửi hồ sơ quá lâu sau ${Math.round(FETCH_SUBMIT_DEADLINE_MS / 1000)} giây. Kiểm tra mạng hoặc file PDF quá lớn, rồi thử lại.`,
);
}
throw err;
}
if (!res.ok) {
let message = res.statusText;
try {
const j = (await res.json()) as { detail?: unknown };
if (typeof j.detail === 'string') {
message = j.detail;
} else if (Array.isArray(j.detail)) {
message = JSON.stringify(j.detail);
} else if (j.detail && typeof j.detail === 'object') {
const d = j.detail as { message?: string; missing?: string[] };
const header = typeof d.message === 'string' ? d.message : '';
const lines = Array.isArray(d.missing) ? d.missing.filter(Boolean).join('\n') : '';
message = [header, lines].filter(Boolean).join('\n') || message;
}
} catch {
try {
message = await res.text();
} catch {
/* ignore */
}
}
throw new Error(message || 'Gửi hồ sơ thất bại');
}
return res.json() as Promise<SubmitInitiativeResponse>;
}
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
reviveContributionDraft,
type ApplicantPrefill,
type ApplicationFormState,
type Author,
type InitiativeFormState,
} from "@ump/shared";
import InitiativeReportForm from "@/components/InitiativeReportForm";
import InitiativeApplicationForm from "@/components/InitiativeApplicationForm";
import ContributionConfirmationForm from "@/components/ContributionConfirmationForm";
import { REVIEW_FORMS_TAB_SEARCH_PARAM } from "@/lib/applicationReviewNavigation";
/**
* Applicant read-only review panel (frontend_user walking-skeleton).
*
* Renders the three submitted forms (Báo cáo / Đơn / Xác nhận đóng góp) — reused verbatim from
* the slice-3 ports. The admin/AI surfaces of the fe0 `ReviewFormsPanel` are intentionally
* deferred to slice 5: the `ChatAssistant` verify panel (no AI ported yet), the council
* « Phiếu đánh giá » tab, and the staff « Mẫu hồ sơ và Minh chứng » bundle-review tab. Applicants
* never see evaluation/bundle anyway (gated off by permission); chat/verify lands with the AI slice.
*
* The prop signature matches the fe0 component so `ApplicationReviewPage` compiles unchanged; the
* chat/evaluation-related props are accepted but unused here.
*/
export type ReviewFormsPanelProps = {
caseKey: string;
applicantPrefill?: ApplicantPrefill;
draftTabs?: {
report?: Record<string, unknown>;
application?: Record<string, unknown>;
contribution?: Record<string, unknown>;
};
/** Submission id (`detail.id`) for evidence vault + evaluation actions (staff review) — slice 5. */
reviewApplicationId?: string;
enableCouncilEvaluationReview?: boolean;
onAdminEvaluationApprove?: () => void | Promise<void>;
onAdminEvaluationReject?: () => void | Promise<void>;
formsReadOnly: boolean;
canViewEvaluation: boolean;
canViewChat: boolean;
canInteractChat: boolean;
canVerify: boolean;
mainPanelSize: number;
chatPanelSize: number;
verificationRequest: { fieldName: string; content: string } | null;
onVerify: (fieldName: string, content: string) => void;
onVerificationHandled: () => void;
showBundleDocxReviewTab?: boolean;
};
/** Đồng bộ tab Xác nhận đóng góp với `authors` của tab Đơn khi không có DraftProvider. */
function authorsForContributionMirror(
applicationTab: Record<string, unknown> | undefined,
): Author[] | undefined {
if (!applicationTab) return undefined;
const raw = applicationTab.authors;
if (!Array.isArray(raw) || raw.length === 0) return undefined;
return raw as Author[];
}
function initiativeNameForContributionMirror(
applicationTab: Record<string, unknown> | undefined,
): string | undefined {
if (!applicationTab) return undefined;
return String((applicationTab as { initiativeName?: unknown }).initiativeName ?? "");
}
export function ReviewFormsPanel({
caseKey,
applicantPrefill,
draftTabs,
formsReadOnly,
}: ReviewFormsPanelProps) {
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState("report");
const mirrorApplicationAuthors = useMemo(
() => authorsForContributionMirror(draftTabs?.application),
[caseKey, draftTabs?.application],
);
const mirrorInitiativeNameFromApplication = useMemo(
() => initiativeNameForContributionMirror(draftTabs?.application),
[caseKey, draftTabs?.application],
);
const reviewTabFromUrl = searchParams.get(REVIEW_FORMS_TAB_SEARCH_PARAM);
useEffect(() => {
const allowed = new Set(["report", "application", "contribution"]);
setActiveTab(reviewTabFromUrl && allowed.has(reviewTabFromUrl) ? reviewTabFromUrl : "report");
}, [caseKey, reviewTabFromUrl]);
return (
<div className="h-full overflow-auto pr-2">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="b-4 flex-wrap h-auto gap-1">
<TabsTrigger value="report">Báo cáo tả Sáng kiến</TabsTrigger>
<TabsTrigger value="application">Đơn Đ nghị Công nhận</TabsTrigger>
<TabsTrigger value="contribution">Xác nhận Tỷ lệ Đóng góp</TabsTrigger>
</TabsList>
<TabsContent value="report">
<InitiativeReportForm
key={`${caseKey}-report`}
applicantPrefill={applicantPrefill}
initialDraft={draftTabs?.report as Partial<InitiativeFormState> | undefined}
readOnly={formsReadOnly}
showVerifyButton={false}
onNext={formsReadOnly ? undefined : () => setActiveTab("application")}
/>
</TabsContent>
<TabsContent value="application">
<InitiativeApplicationForm
key={`${caseKey}-application`}
applicantPrefill={applicantPrefill}
initialDraft={draftTabs?.application as Partial<ApplicationFormState> | undefined}
readOnly={formsReadOnly}
showVerifyButton={false}
onNext={formsReadOnly ? undefined : () => setActiveTab("contribution")}
/>
</TabsContent>
<TabsContent value="contribution">
<ContributionConfirmationForm
key={`${caseKey}-contribution`}
applicantPrefill={applicantPrefill}
initialDraft={reviveContributionDraft(draftTabs?.contribution)}
mirrorApplicationAuthors={mirrorApplicationAuthors}
mirrorInitiativeNameFromApplication={mirrorInitiativeNameFromApplication}
readOnly={formsReadOnly}
showVerifyButton={false}
onNext={formsReadOnly ? undefined : () => setActiveTab("report")}
/>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,43 @@
import { FileText, FileImage, FileType2, Link2, FileSpreadsheet } from "lucide-react";
import type { InferredEvidenceKind } from "@/lib/evidenceFileKind";
const ICON_CLASS: Record<InferredEvidenceKind, string> = {
pdf: "text-red-600",
image: "text-emerald-600",
docx: "text-blue-600",
xlsx: "text-green-700",
link: "text-sky-600",
other: "text-muted-foreground",
};
interface EvidenceFileTypeIconProps {
kind: InferredEvidenceKind;
className?: string;
title?: string;
}
/**
* Small icon for PDF / image / Word / Excel / link / other, for tables and links.
*/
export function EvidenceFileTypeIcon({ kind, className = "h-4 w-4", title }: EvidenceFileTypeIconProps) {
const color = ICON_CLASS[kind] ?? ICON_CLASS.other;
const c = `${className} ${color} shrink-0`.trim();
const icon =
kind === "image" ? (
<FileImage className={c} />
) : kind === "link" ? (
<Link2 className={c} />
) : kind === "xlsx" ? (
<FileSpreadsheet className={c} />
) : kind === "docx" ? (
<FileType2 className={c} />
) : kind === "pdf" ? (
<FileText className={c} />
) : (
<FileType2 className={c} />
);
if (title) {
return <span title={title}>{icon}</span>;
}
return icon;
}
@@ -0,0 +1,70 @@
import type { ApplicationFile, ApplicationItem } from "@/data/mockApplications";
import { inferEvidenceFileKind } from "@/lib/evidenceFileKind";
import { resolveApplicationAssetUrl } from "@/lib/resolveApplicationAssetUrl";
import { EvidenceFileTypeIcon } from "./EvidenceFileTypeIcon";
const FILE_KEYS = ["fullText", "abstract", "poster"] as const;
export type EvidenceFileSlotKey = (typeof FILE_KEYS)[number];
function fileEntry(
key: EvidenceFileSlotKey,
f: ApplicationFile | null | undefined,
): { key: string; file: ApplicationFile; label: string } | null {
if (!f) return null;
const href = (f.viewUrl ?? f.url ?? "").trim();
if (!href) return null;
return {
key,
file: f,
label: key === "fullText" ? "Toàn văn" : key === "abstract" ? "Tóm tắt" : "Poster",
};
}
export interface EvidenceFilesTableCellProps {
item: ApplicationItem;
/** Mã `case_code` (Postgres). Reserved for the evidence vault button — deferred to a later slice. */
caseId: string | undefined;
/** Do not show links for these submitted file slots (e.g. `fullText` in applicant history). */
hideFileKeys?: ReadonlyArray<EvidenceFileSlotKey>;
}
/**
* Ô bảng: các minh chứng đã nộp (pdf, hình, docx, excel, link) qua presigned URL.
* (Nút mở kho minh chứng `EvidenceVaultLinkButton` → `/dashboard/documents/:caseId/evidence`
* tạm bỏ — tuyến kho minh chứng thuộc slice sau.)
*/
export function EvidenceFilesTableCell({ item, hideFileKeys }: EvidenceFilesTableCellProps) {
const keys: EvidenceFileSlotKey[] = hideFileKeys?.length
? FILE_KEYS.filter((k) => !hideFileKeys.includes(k))
: [...FILE_KEYS];
const entries = keys.map((k) => fileEntry(k, item.files?.[k])).filter(Boolean) as {
key: string;
file: ApplicationFile;
label: string;
}[];
if (entries.length === 0) {
return <span className="text-muted-foreground text-xs"></span>;
}
return (
<div className="flex flex-wrap items-center justify-center gap-1.5">
{entries.map((e) => {
const k = inferEvidenceFileKind(e.file);
return (
<a
key={e.key}
href={resolveApplicationAssetUrl(e.file.viewUrl || e.file.url)}
target="_blank"
rel="noreferrer"
className="inline-flex rounded-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`${e.label} — mở tệp đã nộp`}
aria-label={`Mở ${e.label} (${e.file.type || k})`}
>
<EvidenceFileTypeIcon kind={k} title={e.label} />
</a>
);
})}
</div>
);
}
+560
View File
@@ -0,0 +1,560 @@
/**
* Sidebar primitive — faithful reproduction of fe0's shadcn sidebar
* (fe0/src/components/ui/sidebar.tsx), trimmed for the @ump/shared kernel.
*
* Differences vs fe0 (kept intentionally bounded for this shell migration):
* - cn / Button / Input / Separator come from @ump/shared (not local @/lib, @/components/ui).
* - useIsMobile is inlined here (fe0 imports it from @/hooks/use-mobile).
* - No Radix Tooltip dependency: SidebarMenuButton's `tooltip` prop renders via the
* native `title` attribute (still shows the label on the collapsed icon rail).
* - No mobile Sheet / Skeleton sub-deps: the desktop fixed rail renders at all widths.
* Visual classNames + the --sidebar-* design tokens are preserved verbatim.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { cn, Button, Input, Separator } from "@ump/shared";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const MOBILE_BREAKPOINT = 768;
function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(false);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return isMobile;
}
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { state } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
title={tooltip}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+115
View File
@@ -0,0 +1,115 @@
/**
* Shared mock application rows for council list + admin review when API is unavailable.
*/
export type ApplicationStatus =
| "pending"
| "approved"
| "rejected"
| "transferred"
| "under_review"
| "reviewed";
export type ReviewStatus = "not_reviewed" | "under_review" | "reviewed";
export interface Person {
id: string;
name: string;
email?: string;
phone?: string;
}
export interface ApplicationFile {
/** Local/API path or relative URL */
url?: string;
/** Signed GET (e.g. MinIO full PDF) — prefer when present */
viewUrl?: string;
/** Object key when stored in object storage (opaque; for diagnostics) */
storageKey?: string;
type?: "pdf" | "docx" | "image" | string;
}
export interface ApplicationItem {
id: string;
/** Same as `draft_case_id` from API — Initiative.case_code for loading `/api/v1/application-drafts/…` */
draftCaseId?: string;
/** Set by API; mapped to `draftCaseId` in `normalizeApplicationItem` (submission `id` is often `sub-…` instead) */
draft_case_id?: string;
submittedDate: string;
name: string;
author: Person;
/** Calendar year of submission (set by API `/api/applications/mine`) */
calendarYear?: number;
/** Value key for `DEPARTMENT_OPTIONS` / filter `subjectId` (Đơn vị) */
subjectId?: string;
/** Value key for `GROUP_OPTIONS` / filter `topicTypeId` (Nhóm sáng kiến) */
groupId?: string;
conference?: { id: string; name: string; subCategory?: string };
supervisor?: Person | null;
reviewer?: Person | null;
reviewDeadline?: string | null;
/** Legacy display fallback if `groupId` missing */
topicType?: string;
/** From draft tab `application` — drives approved-row merit label (2.1.1 / 2.1.2 → Xuất sắc). */
initiativeClassification?: "technical" | "research" | "textbook" | null;
/** From draft tab `application` — nhóm 2.2 (sách / tài liệu tham khảo). */
textbookEvidenceKind?: "" | "book" | "reference";
status: ApplicationStatus;
reviewStatus?: ReviewStatus;
/** Cột «Đủ và Đúng» (danh sách quản trị); API có thể gửi `du_va_dung` */
duVaDung?: string | null;
/** Cột «Nhận xét» (thẩm định minh chứng); API có thể gửi `nhan_xet` */
nhanXet?: string | null;
files?: {
abstract?: ApplicationFile | null;
poster?: ApplicationFile | null;
fullText?: ApplicationFile | null;
};
}
export const MOCK_APPLICATIONS: ApplicationItem[] = [
{
id: "app-001",
submittedDate: "2026-03-23T22:23:00Z",
name: "KHẢO SÁT TÁC DỤNG KHÁNG VIÊM TỪ DƯỢC LIỆU",
author: { id: "user-042", name: "Lê Thị Kim Ngân", email: "kimngaan1294@gmail.com", phone: "0778093364" },
subjectId: "khoa_y_te_cong_cong",
groupId: "innovation_from_research",
conference: { id: "conf-006", name: "SÁNG THỨ 6", subCategory: "Bệnh không lây nhiễm" },
supervisor: { id: "user-055", name: "Ngô Văn Hùng" },
reviewer: { id: "user-101", name: "Phạm Thị Quỳnh" },
reviewDeadline: "2026-03-31",
topicType: "Tóm tắt báo cáo khoa học",
status: "pending",
reviewStatus: "not_reviewed",
files: {
abstract: { url: "/files/app-001/abstract.pdf", type: "pdf" },
poster: { url: "/files/app-001/poster.docx", type: "docx" },
fullText: null,
},
},
{
id: "app-002",
submittedDate: "2026-03-20T09:10:00Z",
name: "ỨNG DỤNG AI TRONG DỰ BÁO DỊCH TỄ",
author: { id: "user-077", name: "Nguyễn Văn Minh", email: "minh@example.com", phone: "0909555000" },
subjectId: "phong_khoa_hoc_cong_nghe",
groupId: "internal_technical_management",
conference: { id: "conf-004", name: "CHIỀU THỨ 5", subCategory: "Dịch tễ học" },
supervisor: null,
reviewer: { id: "user-102", name: "Trần Thanh Huyền" },
reviewDeadline: "2026-03-22",
topicType: "Nghiên cứu khoa học",
status: "approved",
reviewStatus: "reviewed",
files: {
abstract: { url: "/files/app-002/abstract.pdf", type: "pdf" },
poster: null,
fullText: { url: "/files/app-002/full-text", type: "link" },
},
},
];
export function getMockApplicationById(id: string): ApplicationItem | undefined {
return MOCK_APPLICATIONS.find((a) => a.id === id);
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import {
loadApplicantDraftBundle,
saveApplicantDraftTab,
readInitialApplicantCaseId,
readReportStepDoneFromSession,
reportDraftLooksComplete,
REPORT_STEP_SESSION_KEY,
APPLICANT_CASE_ID_SESSION_KEY,
CASE_ID_SEARCH_PARAM,
FRESH_DASHBOARD_SEARCH_PARAM,
prepareFreshApplicantDashboardNavigation,
} from "@ump/shared";
/**
* Applicant draft session for `/dashboard`:
* - **New application** — `?fresh=1` clears storage and shows empty forms until first save (Postgres).
* - **Resume / review** — `sessionStorage` case id or `?caseId=` loads `GET /api/v1/application-drafts/:caseId`.
*/
export type ApplicantDraftLoadStatus = "idle" | "loading" | "ready" | "error";
export function useApplicantRegistrationState() {
const [searchParams, setSearchParams] = useSearchParams();
const [applicationCaseId, setApplicationCaseId] = useState<string>(readInitialApplicantCaseId);
const [draftTabs, setDraftTabs] = useState<Record<string, Record<string, unknown> | undefined>>({});
const [reportStepCompleted, setReportStepCompleted] = useState(readReportStepDoneFromSession);
const [activeApplicantTab, setActiveApplicantTab] = useState("report");
const [applicantDraftMountKey, setApplicantDraftMountKey] = useState(0);
const [draftLoadStatus, setDraftLoadStatus] = useState<ApplicantDraftLoadStatus>(() =>
readInitialApplicantCaseId() ? "loading" : "idle",
);
/** After `?caseId=` is consumed, the URL becomes plain `/dashboard` — skip one plain-URL reset so we do not wipe the resumed case. */
const skipPlainDashboardResetAfterCaseIdUrl = useRef(false);
/**
* Plain `/dashboard` (no `?fresh=1`, no `?caseId=`) clears session and starts a new registration.
* Resume only via `?caseId=...` in the URL — not when `reportStepCompleted` / `draftTabs` change in-session.
* Dependencies must stay `[searchParams]` only; including draft/report state re-ran this effect after
* "Tiếp theo" on the report tab and undid the tab change.
*/
useEffect(() => {
const isFresh = searchParams.get(FRESH_DASHBOARD_SEARCH_PARAM) === "1";
const hasCaseIdFromUrl = Boolean(searchParams.get(CASE_ID_SEARCH_PARAM)?.trim());
if (isFresh || hasCaseIdFromUrl) return;
if (skipPlainDashboardResetAfterCaseIdUrl.current) {
skipPlainDashboardResetAfterCaseIdUrl.current = false;
return;
}
prepareFreshApplicantDashboardNavigation();
setApplicationCaseId("");
setDraftTabs({});
setReportStepCompleted(false);
setActiveApplicantTab("report");
setDraftLoadStatus("idle");
setApplicantDraftMountKey((k) => k + 1);
}, [searchParams]);
/** `?fresh=1` — empty forms, new draft row on next save (Postgres). */
useEffect(() => {
if (searchParams.get(FRESH_DASHBOARD_SEARCH_PARAM) !== "1") return;
prepareFreshApplicantDashboardNavigation();
setApplicationCaseId("");
setDraftTabs({});
setReportStepCompleted(false);
setActiveApplicantTab("report");
setDraftLoadStatus("idle");
setApplicantDraftMountKey((k) => k + 1);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete(FRESH_DASHBOARD_SEARCH_PARAM);
next.delete(CASE_ID_SEARCH_PARAM);
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
/** `?caseId=` — resume / review in-progress draft from Postgres (after `?fresh` handling). */
useEffect(() => {
if (searchParams.get(FRESH_DASHBOARD_SEARCH_PARAM) === "1") return;
const cid = searchParams.get(CASE_ID_SEARCH_PARAM)?.trim();
if (!cid) return;
try {
sessionStorage.setItem(APPLICANT_CASE_ID_SESSION_KEY, cid);
} catch {
/* ignore */
}
setApplicationCaseId(cid);
setApplicantDraftMountKey((k) => k + 1);
skipPlainDashboardResetAfterCaseIdUrl.current = true;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete(CASE_ID_SEARCH_PARAM);
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
/** Load tab bundle from server when case id is set (Postgres-backed drafts). */
useEffect(() => {
if (!applicationCaseId) {
setDraftLoadStatus((s) => (s === "error" ? s : "idle"));
return;
}
let cancelled = false;
setDraftLoadStatus("loading");
void loadApplicantDraftBundle(applicationCaseId)
.then((bundle) => {
if (cancelled) return;
setDraftTabs(bundle.tabs);
setDraftLoadStatus("ready");
})
.catch(() => {
if (cancelled) return;
setDraftLoadStatus("error");
toast.error("Không tải được bản nháp từ máy chủ. Kiểm tra đăng nhập hoặc thử lại.");
});
return () => {
cancelled = true;
};
}, [applicationCaseId]);
useEffect(() => {
if (reportStepCompleted) return;
if (reportDraftLooksComplete(draftTabs.report)) {
setReportStepCompleted(true);
try {
sessionStorage.setItem(REPORT_STEP_SESSION_KEY, "1");
} catch {
/* ignore */
}
}
}, [draftTabs.report, reportStepCompleted]);
const persistDraft = useCallback(
async (tab: "report" | "application" | "contribution", data: Record<string, unknown>) => {
const bundle = await saveApplicantDraftTab(tab, data, applicationCaseId || undefined);
setApplicationCaseId(bundle.caseId);
try {
sessionStorage.setItem(APPLICANT_CASE_ID_SESSION_KEY, bundle.caseId);
} catch {
/* ignore */
}
setDraftTabs(bundle.tabs);
setDraftLoadStatus("ready");
},
[applicationCaseId],
);
const persistAllDraftTabs = useCallback(
async (payload: {
report: Record<string, unknown>;
application: Record<string, unknown>;
contribution: Record<string, unknown>;
}) => {
await persistDraft("report", payload.report);
await persistDraft("application", payload.application);
await persistDraft("contribution", payload.contribution);
},
[persistDraft],
);
const handleReportNext = useCallback(() => {
try {
sessionStorage.setItem(REPORT_STEP_SESSION_KEY, "1");
} catch {
/* ignore */
}
setReportStepCompleted(true);
setActiveApplicantTab("application");
}, []);
return {
applicationCaseId,
draftTabs,
reportStepCompleted,
activeApplicantTab,
setActiveApplicantTab,
applicantDraftMountKey,
draftLoadStatus,
persistDraft,
persistAllDraftTabs,
handleReportNext,
};
}
@@ -0,0 +1,71 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient, loadApplicantDraftBundle } from "@ump/shared";
import { getMockApplicationById, type ApplicationItem } from "@/data/mockApplications";
import { resolveApplicationDraftCaseId } from "@/lib/resolveApplicationDraftCaseId";
import { syncApplicantSubmissionFromApplicationItem } from "@/lib/applicantSubmissionRecord";
import { axiosSuccessStatusOnly, getNestedData, normalizeApplicationItem } from "@/lib/applicationReviewApi";
export type UseApplicationReviewDetailOptions = {
enabled?: boolean;
/**
* When false (staff reviewing others), do not write `initiative:last-submission-v1` — avoids corrupting the reviewers local applicant shortcut.
*/
syncLocalSubmission?: boolean;
};
export function useApplicationReviewDetail(
applicationId: string,
{ enabled = true, syncLocalSubmission = true }: UseApplicationReviewDetailOptions = {},
) {
const detailQuery = useQuery({
queryKey: ["application-detail", applicationId],
enabled: Boolean(applicationId) && enabled,
queryFn: async (): Promise<ApplicationItem | null> => {
try {
const raw = await apiClient.get(
`/api/applications/${encodeURIComponent(applicationId)}`,
axiosSuccessStatusOnly,
);
const data = getNestedData<ApplicationItem | null>(raw);
if (data && typeof data === "object" && "id" in data) {
const next = normalizeApplicationItem(data as ApplicationItem & { draft_case_id?: string });
if (syncLocalSubmission) {
syncApplicantSubmissionFromApplicationItem(next);
}
return next;
}
} catch (e: unknown) {
const st =
e && typeof e === "object" && "status" in e ? Number((e as { status: unknown }).status) || 0 : 0;
if (st >= 400 && st < 500) return null;
}
const mock = getMockApplicationById(applicationId) ?? null;
if (mock) {
const next = normalizeApplicationItem(mock);
if (syncLocalSubmission) {
syncApplicantSubmissionFromApplicationItem(next);
}
return next;
}
return null;
},
});
const detail = detailQuery.data;
const draftCaseIdForDetail = useMemo(
() => (detail ? resolveApplicationDraftCaseId(detail) : ""),
[detail],
);
const draftBundleQuery = useQuery({
queryKey: ["application-draft-for-documents", applicationId, draftCaseIdForDetail],
enabled: Boolean(applicationId) && Boolean(detail) && Boolean(draftCaseIdForDetail) && enabled,
queryFn: () => loadApplicantDraftBundle(draftCaseIdForDetail),
staleTime: 30_000,
retry: 1,
});
return { detailQuery, draftBundleQuery, detail, draftCaseIdForDetail };
}
+285
View File
@@ -0,0 +1,285 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@layer base {
:root {
/* Warm canvas + cool institutional primary — clearer layers than flat gray-on-cream */
--background: 38 32% 96%;
--foreground: 222 28% 16%;
--card: 0 0% 100%;
--card-foreground: 222 28% 16%;
--popover: 0 0% 100%;
--popover-foreground: 222 28% 16%;
--primary: 217 52% 38%;
--primary-foreground: 210 40% 98%;
--secondary: 220 18% 92%;
--secondary-foreground: 222 28% 22%;
--muted: 36 22% 92%;
--muted-foreground: 220 12% 42%;
--accent: 168 42% 38%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 48%;
--destructive-foreground: 210 40% 98%;
--border: 220 14% 88%;
--input: 220 14% 88%;
--ring: 217 52% 38%;
--radius: 1rem;
/* Custom design tokens — tuned to sit cleanly on the new base */
--tag-financing: 215 48% 78%;
--tag-lifestyle: 162 36% 72%;
--tag-community: 32 42% 62%;
--tag-wellness: 278 32% 74%;
--tag-travel: 198 48% 72%;
--tag-creativity: 328 38% 76%;
--tag-growth: 48 46% 68%;
/* Polish design tokens */
--cream: 40 38% 92%;
--cream-foreground: 222 28% 16%;
--surface-elevated: 0 0% 99%;
--shadow-soft: 222 28% 16%;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar tokens */
--sidebar-background: 220 16% 97%;
--sidebar-foreground: 222 28% 16%;
--sidebar-primary: 217 52% 38%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 220 18% 93%;
--sidebar-accent-foreground: 222 28% 22%;
--sidebar-border: 220 14% 88%;
--sidebar-ring: 217 52% 38%;
}
.dark {
--background: 222 24% 12%;
--foreground: 210 36% 96%;
--card: 222 22% 16%;
--card-foreground: 210 36% 96%;
--popover: 222 24% 12%;
--popover-foreground: 210 36% 96%;
--primary: 213 62% 58%;
--primary-foreground: 222 28% 12%;
--secondary: 217 18% 22%;
--secondary-foreground: 210 36% 96%;
--muted: 220 16% 20%;
--muted-foreground: 220 12% 64%;
--accent: 168 40% 44%;
--accent-foreground: 210 36% 98%;
--destructive: 0 63% 48%;
--destructive-foreground: 210 36% 98%;
--border: 217 16% 24%;
--input: 217 16% 24%;
--ring: 213 62% 58%;
/* Dark mode tag colors */
--tag-financing: 215 42% 42%;
--tag-lifestyle: 162 32% 40%;
--tag-community: 32 34% 42%;
--tag-wellness: 278 28% 46%;
--tag-travel: 198 42% 44%;
--tag-creativity: 328 32% 46%;
--tag-growth: 48 38% 44%;
/* Dark mode polish tokens */
--cream: 222 18% 22%;
--cream-foreground: 210 36% 96%;
--surface-elevated: 222 22% 18%;
--shadow-soft: 222 28% 6%;
/* Sidebar tokens */
--sidebar-background: 222 22% 14%;
--sidebar-foreground: 210 36% 96%;
--sidebar-primary: 213 62% 58%;
--sidebar-primary-foreground: 222 28% 12%;
--sidebar-accent: 217 18% 20%;
--sidebar-accent-foreground: 210 36% 96%;
--sidebar-border: 217 16% 24%;
--sidebar-ring: 213 62% 58%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
html {
scroll-behavior: smooth;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif tracking-tight;
}
}
@layer utilities {
.card-hover {
@apply transition-all duration-500 hover:scale-[1.02];
box-shadow: 0 4px 20px -4px hsl(var(--shadow-soft) / 0.1);
}
.card-hover:hover {
box-shadow: 0 20px 40px -10px hsl(var(--shadow-soft) / 0.15);
}
.pill-nav {
@apply rounded-full bg-[hsl(var(--surface-elevated))] backdrop-blur-lg border border-border/50;
}
.floating-button {
@apply w-12 h-12 rounded-full bg-[hsl(var(--cream)/0.9)] backdrop-blur-sm flex items-center justify-center text-[hsl(var(--cream-foreground))] hover:bg-[hsl(var(--cream))] hover:scale-110 transition-all duration-300;
box-shadow: 0 4px 12px -2px hsl(var(--shadow-soft) / 0.15);
}
/* Animation keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-slide-up {
animation: slideUp 0.6s ease-out;
}
.animate-slide-down {
animation: slideDown 0.6s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.5s ease-out;
}
/* Stagger animations */
.stagger-1 {
animation-delay: 0.1s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-2 {
animation-delay: 0.2s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-3 {
animation-delay: 0.3s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-4 {
animation-delay: 0.4s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-5 {
animation-delay: 0.5s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-6 {
animation-delay: 0.6s;
opacity: 0;
animation-fill-mode: forwards;
}
.tag-financing {
@apply bg-[hsl(var(--tag-financing))] text-primary;
}
.tag-lifestyle {
@apply bg-[hsl(var(--tag-lifestyle))] text-primary;
}
.tag-community {
@apply bg-[hsl(var(--tag-community))] text-primary;
}
.tag-wellness {
@apply bg-[hsl(var(--tag-wellness))] text-primary;
}
.tag-travel {
@apply bg-[hsl(var(--tag-travel))] text-primary;
}
.tag-creativity {
@apply bg-[hsl(var(--tag-creativity))] text-primary;
}
.tag-growth {
@apply bg-[hsl(var(--tag-growth))] text-primary;
}
}
@@ -0,0 +1,38 @@
import { Outlet } from "react-router-dom";
import { Search } from "lucide-react";
import { Input } from "@ump/shared";
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
import { ApplicantDashboardSidebar } from "@/components/applicant/DashboardSidebar";
/**
* Applicant dashboard shell — sidebar + header + routed content (<Outlet/>).
*
* Mirrors fe0/src/layouts/DashboardLayout.tsx, but hardcodes the applicant sidebar
* (this is the applicant app — fe0 swapped admin-vs-applicant at runtime). The header
* is a lean inline reproduction of fe0's DashboardHeader: SidebarTrigger + search look.
* fe0's NotificationBell / UserMenu are deferred with the deep feature pages.
*/
export function DashboardLayout() {
return (
<SidebarProvider>
<div className="flex overflow-hidden h-screen w-full">
<ApplicantDashboardSidebar />
<SidebarInset>
<header className="flex h-16 absolute top-0 left-0 right-0 items-center gap-4 border-b border-border bg-card px-4">
<SidebarTrigger />
<div className="flex-1">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input type="search" placeholder="Tìm kiếm..." className="pl-9 bg-background" />
</div>
</div>
</header>
<main className="flex-1 overflow-auto p-6 mt-12">
<Outlet />
</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}
@@ -0,0 +1,40 @@
import { apiClient } from "@ump/shared";
import type { ApplicationItem } from "@/data/mockApplications";
function unwrap<T>(raw: unknown): T {
if (raw && typeof raw === "object" && "data" in raw && (raw as { data: unknown }).data !== undefined) {
return (raw as { data: T }).data;
}
return raw as T;
}
export type CreateApplicantApplicationResult = {
id: string;
application: ApplicationItem;
};
/**
* `POST /api/applications/new` — creates a shell row and returns `draftCaseId` for form drafts.
*/
export async function createApplicantApplication(
name?: string,
): Promise<CreateApplicantApplicationResult> {
const raw = await apiClient.post<unknown>("/api/applications/new", {
name: (name ?? "").trim(),
});
return unwrap<CreateApplicantApplicationResult>(raw);
}
/**
* `PUT /api/applications/{id}` — update display name and submitted date (metadata only).
*/
export async function updateApplicantApplication(
applicationId: string,
payload: { name: string; submittedDate: string },
): Promise<ApplicationItem> {
const raw = await apiClient.put<unknown>(`/api/applications/${encodeURIComponent(applicationId)}`, {
name: payload.name.trim(),
submittedDate: payload.submittedDate,
});
return unwrap<ApplicationItem>(raw);
}
@@ -0,0 +1,84 @@
/**
* Client-side record of the applicant's last successful submission so they can
* reopen the review page and see status without relying on email/bookmarks alone.
*/
import type { ApplicationItem } from '@/data/mockApplications';
import type { SubmitInitiativeResponse } from '@/components/applicant/submitInitiativePdf';
const STORAGE_KEY = 'initiative:last-submission-v1';
export interface ApplicantSubmissionRecord {
applicationId: string;
caseId?: string;
initiativeName: string;
submittedAt: string;
publicUrl?: string;
/** Mirrors server `reviewStatus` when known */
reviewStatus?: string;
/** Mirrors server `status` when known */
status?: string;
updatedAt: string;
}
function safeParse(raw: string | null): ApplicantSubmissionRecord | null {
if (!raw) return null;
try {
const v = JSON.parse(raw) as ApplicantSubmissionRecord;
if (v && typeof v.applicationId === 'string' && typeof v.initiativeName === 'string') return v;
} catch {
/* ignore */
}
return null;
}
export function loadApplicantSubmissionRecord(): ApplicantSubmissionRecord | null {
if (typeof window === 'undefined') return null;
return safeParse(window.localStorage.getItem(STORAGE_KEY));
}
/** Clears last-submission shortcut (e.g. before starting a new registration). */
export function clearApplicantSubmissionRecord(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
export function saveApplicantSubmissionRecord(
response: SubmitInitiativeResponse,
extra: { caseId?: string; initiativeName: string },
): void {
if (typeof window === 'undefined') return;
const now = new Date().toISOString();
const record: ApplicantSubmissionRecord = {
applicationId: response.id,
caseId: extra.caseId,
initiativeName: extra.initiativeName,
submittedAt: response.submittedDate,
publicUrl: response.publicUrl,
reviewStatus: 'not_reviewed',
status: 'pending',
updatedAt: now,
};
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
}
/** Call when `/api/applications/:id` returns so review status stays in sync. */
export function syncApplicantSubmissionFromApplicationItem(item: ApplicationItem): void {
if (typeof window === 'undefined') return;
const existing = loadApplicantSubmissionRecord();
const now = new Date().toISOString();
const record: ApplicantSubmissionRecord = {
applicationId: item.id,
caseId: existing?.applicationId === item.id ? existing.caseId : item.author.id,
initiativeName: item.name,
submittedAt: item.submittedDate,
publicUrl: existing?.applicationId === item.id ? existing.publicUrl : undefined,
reviewStatus: item.reviewStatus,
status: item.status,
updatedAt: now,
};
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
}
@@ -0,0 +1,46 @@
import type { ApplicationItem } from "@/data/mockApplications";
import type { AxiosRequestConfig } from "axios";
/** Reject 4xx/5xx so callers do not treat FastAPI `{ detail }` JSON as a successful entity body. */
export const axiosSuccessStatusOnly: AxiosRequestConfig = {
validateStatus: (status: number) => status >= 200 && status < 300,
};
export function getNestedData<T>(raw: unknown): T {
if (raw && typeof raw === "object" && "data" in raw && (raw as { data: unknown }).data !== undefined) {
return (raw as { data: T }).data;
}
return raw as T;
}
/**
* Map API `draft_case_id` → `draftCaseId` for Postgres draft load on dashboard.
* (fe0 also normalized a `researchEvidenceKind` field that does not exist on `ApplicationItem`;
* dropped here — it was a no-op mapping that only survived fe0's untyped vite build.)
*/
export function normalizeApplicationItem(
item: ApplicationItem & {
draft_case_id?: string;
du_va_dung?: string | null;
nhan_xet?: string | null;
initiative_classification?: ApplicationItem["initiativeClassification"];
textbook_evidence_kind?: ApplicationItem["textbookEvidenceKind"];
},
): ApplicationItem {
const fromSnake = typeof item.draft_case_id === "string" ? item.draft_case_id.trim() : "";
const fromCamel = item.draftCaseId?.trim() ?? "";
const draftCaseId = fromCamel || fromSnake || undefined;
const duVaDung = item.duVaDung ?? item.du_va_dung ?? undefined;
const nhanXet = item.nhanXet ?? item.nhan_xet ?? undefined;
const initiativeClassification =
item.initiativeClassification ?? item.initiative_classification ?? undefined;
const textbookEvidenceKind = item.textbookEvidenceKind ?? item.textbook_evidence_kind ?? undefined;
return {
...item,
draftCaseId,
duVaDung,
nhanXet,
initiativeClassification,
textbookEvidenceKind,
};
}
@@ -0,0 +1,63 @@
/**
* Shared URLs for application review (submitted forms + draft tabs).
* - Applicants: `/dashboard/documents/review`
* - Quản trị: `/dashboard/admin/applications/review` (không có Phiếu đánh giá).
* - Hội đồng: `/dashboard/council/applications/review` (Phiếu đánh giá).
*/
export const FORMS_VIEW_SEARCH_PARAM = "formsView";
/** Which tab is active in {@link ReviewFormsPanel} (`report` | `application` | `contribution` | `bundle_review` | `evaluation`). */
export const REVIEW_FORMS_TAB_SEARCH_PARAM = "reviewTab";
export const APPLICANT_DOCUMENTS_REVIEW_PATH = "/dashboard/documents/review";
export const STAFF_APPLICATION_REVIEW_PATH = "/dashboard/admin/applications/review";
/** Hội đồng (editor): Phiếu đánh giá + duyệt. */
export const COUNCIL_APPLICATION_REVIEW_PATH = "/dashboard/council/applications/review";
export function buildApplicationReviewHref(options: {
applicationId: string;
returnTo: string;
/** Read-only forms (same as applicant “eye” action). */
viewOnly?: boolean;
}): string {
const { applicationId, returnTo, viewOnly = false } = options;
const sp = new URLSearchParams();
sp.set("applicationId", applicationId);
sp.set("returnTo", returnTo);
if (viewOnly) sp.set(FORMS_VIEW_SEARCH_PARAM, "1");
return `${APPLICANT_DOCUMENTS_REVIEW_PATH}?${sp.toString()}`;
}
/** Admin inbox: staff review route (no Phiếu đánh giá tab). */
export function buildStaffApplicationReviewHref(options: {
applicationId: string;
returnTo: string;
viewOnly?: boolean;
reviewTab?: "report" | "application" | "contribution" | "bundle_review" | "evaluation";
}): string {
const { applicationId, returnTo, viewOnly = false, reviewTab } = options;
const sp = new URLSearchParams();
sp.set("applicationId", applicationId);
sp.set("returnTo", returnTo);
if (viewOnly) sp.set(FORMS_VIEW_SEARCH_PARAM, "1");
if (reviewTab) sp.set(REVIEW_FORMS_TAB_SEARCH_PARAM, reviewTab);
return `${STAFF_APPLICATION_REVIEW_PATH}?${sp.toString()}`;
}
/** Hội đồng: council review route (includes Phiếu đánh giá). */
export function buildCouncilApplicationReviewHref(options: {
applicationId: string;
returnTo: string;
viewOnly?: boolean;
reviewTab?: "report" | "application" | "contribution" | "bundle_review" | "evaluation";
}): string {
const { applicationId, returnTo, viewOnly = false, reviewTab } = options;
const sp = new URLSearchParams();
sp.set("applicationId", applicationId);
sp.set("returnTo", returnTo);
if (viewOnly) sp.set(FORMS_VIEW_SEARCH_PARAM, "1");
if (reviewTab) sp.set(REVIEW_FORMS_TAB_SEARCH_PARAM, reviewTab);
return `${COUNCIL_APPLICATION_REVIEW_PATH}?${sp.toString()}`;
}
+43
View File
@@ -0,0 +1,43 @@
import type { ApplicationFile } from "@/data/mockApplications";
export type InferredEvidenceKind = "pdf" | "image" | "docx" | "xlsx" | "link" | "other";
const LINK_RE = /^https?:\/\//i;
/** Guess icon kind from `ApplicationFile.type`, URL, or file name. */
export function inferEvidenceFileKind(
file: Pick<ApplicationFile, "type" | "url" | "viewUrl"> & { originalName?: string | null },
): InferredEvidenceKind {
const t = String(file.type || "").toLowerCase();
const url = String(file.viewUrl || file.url || "");
if (t === "link" || LINK_RE.test(url)) {
return "link";
}
if (t === "pdf" || t.includes("pdf")) return "pdf";
if (t === "image" || t === "img" || t.includes("image")) return "image";
if (t === "docx" || t.includes("word") || t.includes("document")) return "docx";
if (t === "xlsx" || t.includes("sheet") || t.includes("excel")) return "xlsx";
const lower = url.toLowerCase();
const name = (file.originalName || url.split("/").pop() || "").toLowerCase();
if (LINK_RE.test(url)) return "link";
if (lower.includes(".pdf") || name.endsWith(".pdf")) return "pdf";
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(lower) || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)) {
return "image";
}
if (/\.docx?$/i.test(name) || lower.includes(".docx")) return "docx";
if (/\.xlsx?$/i.test(name) || lower.includes(".xlsx") || name.endsWith(".xls")) return "xlsx";
if (t) return "other";
return "other";
}
export function formatEvidenceMimeLabel(mime?: string | null, originalName?: string | null): string {
if (originalName) return originalName;
if (!mime) return "Tệp đính kèm";
const m = mime.toLowerCase();
if (m.includes("pdf")) return "PDF";
if (m.startsWith("image/")) return "Hình ảnh";
if (m.includes("word") || m.includes("document")) return "Word";
if (m.includes("sheet") || m.includes("excel")) return "Excel";
return mime;
}
@@ -0,0 +1,64 @@
/**
* Orchestrates draft save → PDF render → multipart submit.
* Keeps ReviewPanel focused on UI; submission steps stay testable in one place.
*/
import { renderFullDraftPdfBlob } from '@/components/applicant/initiative-draft/pdfExport';
import {
submitInitiativePdf,
type SubmitInitiativeMetadata,
type SubmitInitiativeResponse,
} from '@/components/applicant/submitInitiativePdf';
import type { InitiativeDraft } from '@ump/shared';
function safePdfBaseName(initiativeName: string, fallbackId: string): string {
const raw = initiativeName.trim() || fallbackId;
const cleaned = raw.replace(/[^a-zA-Z0-9_\-\u00C0-\u024F\s]+/g, '-').trim();
return cleaned || 'ho-so-sang-kien';
}
export interface SubmitInitiativeWorkflowInput {
draft: InitiativeDraft;
/** Initiative `case_code` — required to embed 2.1/2.2 evidence PDFs from MinIO in the rendered PDF. */
initiativeCaseId?: string | null;
metadata: SubmitInitiativeMetadata;
onSaveAllDrafts?: (payload: {
report: Record<string, unknown>;
application: Record<string, unknown>;
contribution: Record<string, unknown>;
}) => Promise<void>;
contributionDraft?: Record<string, unknown>;
}
/**
* Persists all tabs (when handler provided), builds the full PDF, uploads to the API.
*/
export async function runInitiativeSubmitWorkflow(
input: SubmitInitiativeWorkflowInput,
): Promise<SubmitInitiativeResponse> {
const { draft, metadata, onSaveAllDrafts, contributionDraft, initiativeCaseId } = input;
if (onSaveAllDrafts) {
await onSaveAllDrafts({
report: draft.report as unknown as Record<string, unknown>,
application: draft.application as unknown as Record<string, unknown>,
contribution: contributionDraft ?? {},
});
}
const initiativeName =
draft.application.initiativeName?.trim() || draft.report.initiativeName?.trim() || '';
if (!initiativeName) {
throw new Error('Vui lòng điền tên sáng kiến ở Đơn hoặc Báo cáo trước khi nộp.');
}
const blob = await renderFullDraftPdfBlob(draft, initiativeCaseId);
const base = safePdfBaseName(initiativeName, draft.draftId);
const fileName = `${base}.pdf`;
const resolvedCase = initiativeCaseId?.trim() || metadata.initiativeCaseId?.trim();
return submitInitiativePdf(blob, fileName, {
...metadata,
initiativeCaseId: resolvedCase || metadata.initiativeCaseId,
caseId: resolvedCase ?? metadata.caseId,
});
}
@@ -0,0 +1,18 @@
import { env } from "@ump/shared";
/**
* Submitted PDFs are stored under `/submitted-initiatives/...` on the API host.
* When `VITE_API_URL` points at the backend, prefix so links open the correct origin.
* When API_URL is empty (dev same-origin), Vite proxy serves the path unchanged.
*/
export function resolveApplicationAssetUrl(url: string | undefined | null): string {
if (url == null || typeof url !== "string") return "";
const u = url.trim();
if (!u) return u;
if (/^https?:\/\//i.test(u)) return u;
if (u.startsWith("/submitted-initiatives")) {
const base = env.API_URL.replace(/\/$/, "");
return base ? `${base}${u}` : u;
}
return u;
}
@@ -0,0 +1,33 @@
import type { ApplicationItem } from "@/data/mockApplications";
import { loadApplicantSubmissionRecord } from "@/lib/applicantSubmissionRecord";
/** `submissionRecord.author.id` may be a user id; case codes are usually `SUB-…` / `DRAFT-…` or not UUID-shaped. */
function looksLikeBareUuid(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s.trim());
}
/**
* Case id for `GET /api/v1/application-drafts/:caseId` (dashboard forms).
* Prefer server field, then the last local submission for this application, else application id.
*/
export function resolveApplicationDraftCaseId(item: ApplicationItem): string {
const fromApi = item.draftCaseId?.trim();
if (fromApi) return fromApi;
const rec = loadApplicantSubmissionRecord();
if (rec?.applicationId === item.id) {
const fromRec = rec.caseId?.trim();
if (fromRec) return fromRec;
}
// Postgres: submission id is `sub-…` but drafts live under `Initiative.case_code` (also exposed as author.id / draft_case_id).
const aid = item.author?.id?.trim();
const rowId = item.id.trim();
const rowIsSubmissionKey = /^sub-/i.test(rowId);
if (aid && rowIsSubmissionKey && aid.toLowerCase() !== rowId.toLowerCase()) {
if (looksLikeBareUuid(aid)) {
// Avoid using a stored user id as the draft case key; caller should refresh from GET /api/applications/:id.
return "";
}
return aid;
}
return rowId;
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -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>
);
}
+95
View File
@@ -0,0 +1,95 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}", "../shared/src/**/*.{ts,tsx}"],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Merriweather', 'Georgia', 'serif'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@ump/shared": ["../shared/src/index.ts"]
},
"types": ["vite/client"]
},
"include": ["src"]
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
// Proxy /api → be0 (locally :4402; in Docker set VITE_DEV_PROXY_TARGET=http://be0:4402).
const apiProxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:4402';
// Vite 5 checks the Host header; allow all when VITE_ALLOWED_HOSTS is unset, else the list.
const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? '')
.split(',')
.map((h) => h.trim())
.filter(Boolean);
export default defineConfig(({ mode }) => ({
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: allowedHosts.length > 0 ? allowedHosts : true,
proxy: {
'/api': { target: apiProxyTarget, changeOrigin: true, secure: false },
'/submitted-initiatives': { target: apiProxyTarget, changeOrigin: true, secure: false },
},
},
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
// Consume the shared kernel as source (no build step) — Vite transpiles it.
'@ump/shared': path.resolve(__dirname, '../shared/src/index.ts'),
},
},
build: {
// Never ship source maps — keeps the original TypeScript un-reconstructable in DevTools.
sourcemap: false,
},
esbuild: {
// Strip console/debugger from production bundles so no debug/internal info leaks.
drop: mode === 'production' ? ['console', 'debugger'] : [],
},
}));