sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
@@ -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 XÃ 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ọ và 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% để có 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 là trung thực, đúng sự thật và 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ữ ký và ghi rõ 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 ký, thông tin tác giả, vai trò với bài báo và 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 ký</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 ký 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="8–12 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 là 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 là sản phẩm của nhiệm vụ NCKH / đề tài: tôi xác nhận đúng quy định về quyền và
|
||||
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 ký.</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 "Tạp chí săn mồi" (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 (ký 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 ký</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 KÝ</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dj-tacgia">Tác giả đăng ký 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="8–12 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 VÀ 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 kê khai và minh chứng đính kèm đối với bài báo trên tạp chí trong nước là trung
|
||||
thực, đúng sự thật và phù hợp với thời điểm xuất bản trong giai đoạn quy định (15/4/2025–15/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 và trước nhà trường về tính hợp pháp của bài báo và nội dung
|
||||
đăng ký.
|
||||
</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ồ sơ khi được yêu cầu.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dj-nguoicamket">Người cam kết (Ký tên, ghi rõ 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 và 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 ký</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 ký 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="8–12 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 và 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 kê khai và minh chứng đính kèm đối với tài liệu tham khảo là trung thực, đúng sự
|
||||
thật và phù hợp với Quyết định xuất bản trong giai đoạn quy định (15/4/2025–15/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 và trước nhà trường về tính hợp pháp của tài liệu và nội dung
|
||||
đăng ký.
|
||||
</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ồ sơ khi được yêu cầu.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rm-nguoicamket">Người cam kết (ký 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 Mô 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ồ sơ đã 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 ký 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ồ sơ.</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-10 text-center text-muted-foreground">Không có hồ sơ 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ồ sơ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
Bạn có chắc muốn xóa hồ sơ <span className="font-medium text-foreground">"{deleteTarget.name}"</span>? Thao tác này không
|
||||
thể hoàn tác. Mã hồ sơ: <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ồ sơ
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+490
@@ -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; có 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ồ sơ (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 có 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 là trung thực, đúng sự thật và 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';
|
||||
+47
@@ -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à 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 XÃ 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 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
|
||||
</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ị có 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 ký: {bck.ngay_ky.ngay}/{bck.ngay_ky.thang}/{bck.ngay_ky.nam}
|
||||
</Text>
|
||||
<Text style={styles.body}>Tác giả đăng ký: {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 ký: {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 ký: {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 ký: {bck.ngay_ky.ngay}/{bck.ngay_ky.thang}/{bck.ngay_ky.nam}
|
||||
</Text>
|
||||
<Text style={styles.body}>Tác giả đăng ký: {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 ký: {rm.ngay_ky.ngay}/{rm.ngay_ky.thang}/{rm.ngay_ky.nam}
|
||||
</Text>
|
||||
<Text style={styles.body}>Tác giả đăng ký: {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}>(Ký và đó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ữ ký và ghi rõ 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) là:</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}>
|
||||
(Mô 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 có):</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 là trung thực, đúng sự thật và 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 MÔ 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ị / cá 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 có)</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 Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 reviewer’s 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 };
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 applicant’s own submission (not admin reviewing another user). */
|
||||
const showApplicantMineSwitcher =
|
||||
useApplicantMine &&
|
||||
(!applicationId || mineList.some((a) => a.id === applicationId));
|
||||
|
||||
const [verificationRequest, setVerificationRequest] = useState<{ fieldName: string; content: string } | null>(null);
|
||||
const handleVerify = useCallback(
|
||||
(fieldName: string, content: string) => {
|
||||
if (canVerify) setVerificationRequest({ fieldName, content });
|
||||
},
|
||||
[canVerify],
|
||||
);
|
||||
const handleVerificationHandled = useCallback(() => setVerificationRequest(null), []);
|
||||
|
||||
const navigateToViewApplication = useCallback(
|
||||
(item: ApplicationItem) => {
|
||||
setFormsBlankSlate(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["application-detail", item.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["application-draft-for-documents"] });
|
||||
navigate(buildApplicationReviewHref({ applicationId: item.id, returnTo, viewOnly: true }));
|
||||
},
|
||||
[queryClient, navigate, returnTo],
|
||||
);
|
||||
|
||||
const navigateToEditApplicationForms = useCallback(
|
||||
async (item: ApplicationItem) => {
|
||||
let normalized = normalizeApplicationItem(item as ApplicationItem & { draft_case_id?: string });
|
||||
if (!normalized.draftCaseId) {
|
||||
try {
|
||||
const raw = await apiClient.get(
|
||||
`/api/applications/${encodeURIComponent(item.id)}`,
|
||||
axiosSuccessStatusOnly,
|
||||
);
|
||||
const data = getNestedData<ApplicationItem | null>(raw);
|
||||
if (data && typeof data === "object" && "id" in data) {
|
||||
normalized = normalizeApplicationItem(data as ApplicationItem & { draft_case_id?: string });
|
||||
}
|
||||
} catch {
|
||||
/* list row + resolveApplicationDraftCaseId fallbacks */
|
||||
}
|
||||
}
|
||||
const caseId = resolveApplicationDraftCaseId(normalized);
|
||||
if (!caseId) {
|
||||
toast.error("Không xác định được mã bản nháp (case) cho hồ sơ này.");
|
||||
return;
|
||||
}
|
||||
syncApplicantSubmissionFromApplicationItem(normalized);
|
||||
navigate(getResumeApplicantDashboardHref(caseId));
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const { detailQuery, draftBundleQuery, detail, draftCaseIdForDetail } = useApplicationReviewDetail(applicationId, {
|
||||
enabled: Boolean(applicationId) && !isDocumentsListRoute,
|
||||
syncLocalSubmission: !isStaffReviewer,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (applicationId: string) => {
|
||||
await apiClient.delete(`/api/applications/${applicationId}`);
|
||||
},
|
||||
onSuccess: async (_, deletedId) => {
|
||||
toast.success("Đã xóa hồ sơ.");
|
||||
await mineQuery.refetch();
|
||||
if (applicationId === deletedId) {
|
||||
if (pathNorm.endsWith("/documents/review")) {
|
||||
navigate("/dashboard/documents", { replace: true });
|
||||
} else {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
sp.delete("applicationId");
|
||||
setSearchParams(sp);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Không thể xóa hồ sơ.");
|
||||
},
|
||||
});
|
||||
|
||||
const handleApplicationDialogSubmit = useCallback(
|
||||
async (payload: { name: string; submittedDate: string }) => {
|
||||
const { mode, item } = applicationDialog;
|
||||
if (mode === "create") {
|
||||
setApplicationDialogSubmitting(true);
|
||||
try {
|
||||
const res = await createApplicantApplication(payload.name);
|
||||
const row = res.application;
|
||||
if (!row || typeof row !== "object") {
|
||||
throw new Error("Máy chủ không trả dữ liệu hồ sơ mới.");
|
||||
}
|
||||
const normalized = normalizeApplicationItem(row as ApplicationItem & { draft_case_id?: string });
|
||||
syncApplicantSubmissionFromApplicationItem(normalized);
|
||||
const caseId = resolveApplicationDraftCaseId(normalized);
|
||||
if (!caseId) {
|
||||
throw new Error("Máy chủ không trả mã bản nháp (case) cho biểu mẫu.");
|
||||
}
|
||||
await mineQuery.refetch();
|
||||
setApplicationDialog({ mode: null, item: null });
|
||||
toast.success("Đã tạo hồ sơ. Chuyển tới bước điền biểu mẫu…");
|
||||
navigate(getResumeApplicantDashboardHref(caseId));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Không tạo được hồ sơ.");
|
||||
} finally {
|
||||
setApplicationDialogSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mode === "edit" && item) {
|
||||
setApplicationDialogSubmitting(true);
|
||||
try {
|
||||
const raw = await updateApplicantApplication(item.id, payload);
|
||||
const updated = normalizeApplicationItem(raw as ApplicationItem & { draft_case_id?: string });
|
||||
syncApplicantSubmissionFromApplicationItem(updated);
|
||||
await mineQuery.refetch();
|
||||
await queryClient.invalidateQueries({ queryKey: ["application-detail", item.id] });
|
||||
setApplicationDialog({ mode: null, item: null });
|
||||
toast.success("Đã cập nhật thông tin hồ sơ.");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Không cập nhật được hồ sơ.");
|
||||
} finally {
|
||||
setApplicationDialogSubmitting(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applicationDialog, mineQuery, navigate, queryClient],
|
||||
);
|
||||
|
||||
const applicantCrudHandlers = useMemo(
|
||||
() => ({
|
||||
onViewApplication: navigateToViewApplication,
|
||||
onEditApplicationForms: canEditApplication ? navigateToEditApplicationForms : undefined,
|
||||
onEditApplicationMetadata: canEditApplication ? openApplicationMetadataDialog : undefined,
|
||||
onDeleteApplication: async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
},
|
||||
}),
|
||||
[
|
||||
navigateToViewApplication,
|
||||
navigateToEditApplicationForms,
|
||||
openApplicationMetadataDialog,
|
||||
canEditApplication,
|
||||
deleteMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const applicantPrefill = useMemo(
|
||||
() =>
|
||||
detail
|
||||
? {
|
||||
initiativeName: detail.name,
|
||||
authorName: detail.author.name,
|
||||
}
|
||||
: undefined,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const mainPanelSize = canViewChat ? 70 : 100;
|
||||
const chatPanelSize = canViewChat ? 30 : 0;
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Đang xác thực…</div>;
|
||||
}
|
||||
|
||||
if (isDocumentsListRoute && applicationId) {
|
||||
if (isStaffReviewer) {
|
||||
return <Navigate to={`${staffApplicationReviewPath}?${searchParams.toString()}`} replace />;
|
||||
}
|
||||
return <Navigate to={`/dashboard/documents/review?${searchParams.toString()}`} replace />;
|
||||
}
|
||||
|
||||
if (isStaffReviewer && applicationId && pathNorm.endsWith("/documents/review")) {
|
||||
return <Navigate to={`${staffApplicationReviewPath}?${searchParams.toString()}`} replace />;
|
||||
}
|
||||
|
||||
const documentsFreshHref = `/dashboard/documents?${FRESH_DASHBOARD_SEARCH_PARAM}=1&returnTo=${encodeURIComponent(returnTo)}`;
|
||||
|
||||
if (!applicationId && formsBlankSlate) {
|
||||
if (useApplicantMine && mineQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Đang tải…</div>;
|
||||
}
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] overflow-hidden animate-fade-in">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Button variant="ghost" size="sm" asChild className="gap-2 -ml-2">
|
||||
<Link to={returnTo}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Về Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Biểu mẫu trống</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-3xl">
|
||||
Đã xóa dữ liệu nháp trên trình duyệt (mã hồ sơ, bước báo cáo, lối tắt hồ sơ vừa nộp). Các ô nhập dùng giá trị mặc định rỗng — bạn có thể điền lại từ đầu hoặc chọn một hồ sơ đã nộp bên dưới.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={documentsFreshHref} reloadDocument>
|
||||
Làm trống lại
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" onClick={openApplicationCreateDialog}>
|
||||
Đăng kí mới
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={documentsFreshHref} reloadDocument>
|
||||
Biểu mẫu trống (làm mới)
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{showApplicantMineSwitcher ? (
|
||||
<ApplicantHistoryPanel
|
||||
items={mineList}
|
||||
loading={mineQuery.isLoading}
|
||||
error={mineQuery.isError}
|
||||
mutating={deleteMutation.isPending}
|
||||
selectedApplicationId=""
|
||||
onViewApplication={applicantCrudHandlers.onViewApplication}
|
||||
onCreateApplication={openApplicationCreateDialog}
|
||||
onEditApplicationForms={applicantCrudHandlers.onEditApplicationForms}
|
||||
onEditApplicationMetadata={applicantCrudHandlers.onEditApplicationMetadata}
|
||||
onDeleteApplication={applicantCrudHandlers.onDeleteApplication}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ApplicantHistoryCrudDialog
|
||||
open={applicationDialog.mode !== null}
|
||||
mode={applicationDialog.mode === "edit" ? "edit" : "create"}
|
||||
initialItem={applicationDialog.item ?? undefined}
|
||||
submitting={applicationDialogSubmitting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setApplicationDialog({ mode: null, item: null });
|
||||
}}
|
||||
onSubmit={handleApplicationDialogSubmit}
|
||||
/>
|
||||
|
||||
<ReviewFormsPanel
|
||||
caseKey="blank-slate"
|
||||
applicantPrefill={undefined}
|
||||
formsReadOnly={false}
|
||||
canViewEvaluation={canViewEvaluation}
|
||||
canViewChat={canViewChat}
|
||||
canInteractChat={canInteractChat}
|
||||
canVerify={canVerify}
|
||||
mainPanelSize={mainPanelSize}
|
||||
chatPanelSize={chatPanelSize}
|
||||
verificationRequest={verificationRequest}
|
||||
onVerify={handleVerify}
|
||||
onVerificationHandled={handleVerificationHandled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!applicationId) {
|
||||
if (useApplicantMine && mineQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Đang tải hồ sơ từ máy chủ…</div>;
|
||||
}
|
||||
if (useApplicantMine && mineQuery.isError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg space-y-4">
|
||||
<p className="text-destructive text-sm">
|
||||
Không tải được danh sách hồ sơ. Đăng nhập lại hoặc thử sau.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const last = loadApplicantSubmissionRecord();
|
||||
const reviewLink =
|
||||
last &&
|
||||
buildApplicationReviewHref({ applicationId: last.applicationId, returnTo: "/dashboard" });
|
||||
return (
|
||||
<div className={useApplicantMine ? "space-y-4" : "mx-auto max-w-lg space-y-4"}>
|
||||
{showApplicantMineSwitcher ? (
|
||||
<ApplicantHistoryPanel
|
||||
items={mineList}
|
||||
loading={mineQuery.isLoading}
|
||||
error={mineQuery.isError}
|
||||
mutating={deleteMutation.isPending}
|
||||
selectedApplicationId=""
|
||||
onViewApplication={applicantCrudHandlers.onViewApplication}
|
||||
onCreateApplication={openApplicationCreateDialog}
|
||||
onEditApplicationForms={applicantCrudHandlers.onEditApplicationForms}
|
||||
onEditApplicationMetadata={applicantCrudHandlers.onEditApplicationMetadata}
|
||||
onDeleteApplication={applicantCrudHandlers.onDeleteApplication}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ApplicantHistoryCrudDialog
|
||||
open={applicationDialog.mode !== null}
|
||||
mode={applicationDialog.mode === "edit" ? "edit" : "create"}
|
||||
initialItem={applicationDialog.item ?? undefined}
|
||||
submitting={applicationDialogSubmitting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setApplicationDialog({ mode: null, item: null });
|
||||
}}
|
||||
onSubmit={handleApplicationDialogSubmit}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Đang tải hồ sơ…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild className="gap-2">
|
||||
<Link to={returnTo}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Về Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-muted-foreground">Không tìm thấy hồ sơ với mã đã chọn.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const reviewStatusLabel = detail.reviewStatus ?? "—";
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden animate-fade-in">
|
||||
<div className="mb-4 flex shrink-0 flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Button variant="ghost" size="sm" asChild className="gap-2 -ml-2">
|
||||
<Link to={`/dashboard/documents?returnTo=${encodeURIComponent(returnTo)}`} reloadDocument>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Quay lại danh sách
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Hồ sơ người nộp đơn</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-3xl">
|
||||
Xem các biểu mẫu đã gửi: Báo cáo mô tả, Đơn đề nghị, Xác nhận đóng góp
|
||||
{canViewEvaluation ? ", Phiếu đánh giá (Hội đồng)" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<Badge variant="outline">
|
||||
Mã hồ sơ: {detail.id}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Đánh giá:</span>
|
||||
<Badge variant="secondary">{reviewStatusLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{draftBundleQuery.isError ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-500 py-2">
|
||||
Không tải được bản nháp đầy đủ (chỉ hiển thị tên hồ sơ từ danh sách). Bạn có thể chỉnh trên Dashboard nếu cần.
|
||||
</p>
|
||||
) : null}
|
||||
<ReviewFormsPanel
|
||||
caseKey={draftCaseIdForDetail || applicationId}
|
||||
applicantPrefill={applicantPrefill}
|
||||
draftTabs={draftBundleQuery.data?.tabs}
|
||||
formsReadOnly={reviewFormsReadOnly}
|
||||
canViewEvaluation={canViewEvaluation}
|
||||
canViewChat={canViewChat}
|
||||
canInteractChat={canInteractChat}
|
||||
canVerify={canVerify}
|
||||
mainPanelSize={mainPanelSize}
|
||||
chatPanelSize={chatPanelSize}
|
||||
verificationRequest={verificationRequest}
|
||||
onVerify={handleVerify}
|
||||
onVerificationHandled={handleVerificationHandled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Construction } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@ump/shared";
|
||||
|
||||
/**
|
||||
* Placeholder for applicant sidebar targets whose feature pages have not been
|
||||
* migrated into frontend_user yet (initiative draft, profile, notifications, …).
|
||||
* Styled with the shared design tokens so the shell looks complete and nothing 404s.
|
||||
*/
|
||||
export function ComingSoonPage() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Construction className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="font-serif">Tính năng đang phát triển</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chức năng này sẽ sớm được hoàn thiện. Vui lòng quay lại sau.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApplicantRegistrationDashboard } from "@/components/applicant/dashboard/ApplicantRegistrationDashboard";
|
||||
|
||||
/**
|
||||
* Applicant dashboard landing (the /dashboard index).
|
||||
*
|
||||
* Renders the initiative-registration workspace ported from fe0
|
||||
* (`ApplicantRegistrationDashboard`): the Báo cáo → Đơn → Đóng góp → Xem lại tab
|
||||
* shell with report-gate locking and Postgres-backed draft lifecycle. In this
|
||||
* walking-skeleton slice the four tab forms + ReviewPanel are placeholders; the real
|
||||
* forms (slice 3) and preview/export (slice 4) drop in at their fe0-mirror paths.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
return <ApplicantRegistrationDashboard />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoginRegisterCard } from '@ump/shared';
|
||||
|
||||
export function LoginPage() {
|
||||
return <LoginRegisterCard registerPath="/register" registerLabel="Đăng ký" />;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Download, FileText } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Badge,
|
||||
listTemplates,
|
||||
renderTemplate,
|
||||
arrayBufferToObjectUrl,
|
||||
saveArrayBufferAs,
|
||||
TEMPLATE_PDF_MIME,
|
||||
detailFromApiError,
|
||||
} from '@ump/shared';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Applicant flow: pick an admin-managed template → fill a form generated from its fields →
|
||||
* render a PDF on the server (docxtpl + LibreOffice) → preview + download.
|
||||
*/
|
||||
export default function TemplatesFillPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const templatesQuery = useQuery({ queryKey: ['templates'], queryFn: listTemplates });
|
||||
const templates = templatesQuery.data ?? [];
|
||||
const selected = useMemo(
|
||||
() => templates.find((t) => t.id === selectedId) ?? null,
|
||||
[templates, selectedId],
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
return <FillTemplate template={selected} onBack={() => setSelectedId(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Mẫu tài liệu</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chọn một mẫu, điền thông tin và tải về tệp PDF hoàn chỉnh.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{templatesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Đang tải…</p>
|
||||
) : templatesQuery.isError ? (
|
||||
<p className="text-sm text-destructive">Không tải được danh sách mẫu.</p>
|
||||
) : templates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-muted-foreground">
|
||||
Hiện chưa có mẫu nào. Vui lòng quay lại sau.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((t) => (
|
||||
<Card key={t.id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{t.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground">
|
||||
{t.description || 'Không có mô tả.'}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<Badge variant="secondary">{t.fields.length} trường</Badge>
|
||||
<Button size="sm" onClick={() => setSelectedId(t.id)}>
|
||||
Điền mẫu
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FillTemplate({
|
||||
template,
|
||||
onBack,
|
||||
}: {
|
||||
template: { id: string; name: string; fields: { key: string; label: string; type: string }[] };
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [rendering, setRendering] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [pdfBuf, setPdfBuf] = useState<ArrayBuffer | null>(null);
|
||||
|
||||
// Revoke the object URL when it changes / on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pdfUrl) URL.revokeObjectURL(pdfUrl);
|
||||
};
|
||||
}, [pdfUrl]);
|
||||
|
||||
const handleRender = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setRendering(true);
|
||||
try {
|
||||
const buf = await renderTemplate(template.id, values, 'pdf');
|
||||
setPdfBuf(buf);
|
||||
setPdfUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return arrayBufferToObjectUrl(buf, TEMPLATE_PDF_MIME);
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(detailFromApiError(err, 'Không tạo được PDF.'));
|
||||
} finally {
|
||||
setRendering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-2 gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Chọn mẫu khác
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{template.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Điền thông tin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{template.fields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mẫu này không có trường nào để điền — bạn vẫn có thể tạo PDF.
|
||||
</p>
|
||||
) : null}
|
||||
<form onSubmit={handleRender} className="space-y-4">
|
||||
{template.fields.map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<Label htmlFor={`f-${f.key}`}>{f.label}</Label>
|
||||
<Input
|
||||
id={`f-${f.key}`}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
disabled={rendering}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button type="submit" disabled={rendering} className="w-full">
|
||||
{rendering ? 'Đang tạo PDF…' : 'Tạo PDF'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">Xem trước PDF</CardTitle>
|
||||
{pdfBuf ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => saveArrayBufferAs(pdfBuf, `${template.name}.pdf`, TEMPLATE_PDF_MIME)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Tải về
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{pdfUrl ? (
|
||||
<iframe title="PDF preview" src={pdfUrl} className="h-[70vh] w-full rounded-md border" />
|
||||
) : (
|
||||
<div className="flex h-[70vh] items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
|
||||
Điền thông tin rồi bấm « Tạo PDF » để xem trước.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'] : [],
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user