diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..eed81c69a --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# emaui environment +# +# Vite reads this from the workspace root, not from the app directory: +# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set +# `envDir: '../../'`. So copy this file to ./.env here, at the repo root. +# +# cp .env.example .env +# +# Only VITE_-prefixed values reach the browser, and everything that does is +# public — it ships inside the built bundle. Never put a secret here. The Fayda +# client id, private key and endpoints live in the API's environment only; the +# frontend never speaks to Fayda directly. + +# Base URL of the emaapi backend, including the /api prefix. +# 3001, not 3000: the portal itself takes 3000 in development, because that is +# the port in the Fayda redirect URI registered for local testing. Set PORT=3001 +# in emaapi's .env to match. +VITE_BASE_API_URL=http://localhost:3001/api + +# Serve fixture data instead of calling the API. Any value other than "true" +# uses the real backend. +VITE_USE_MOCKS=false + +# --- Docker Compose only ----------------------------------------------------- +# Host ports published by docker-compose.yml. It also expects per-app env files +# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this +# file. Ignored when running the Vite dev servers, which serve the portal on +# 3000 and the backoffice on 4201. +# EMA_PORTAL_PORT=8021 +# EMA_BACKOFFICE_PORT=8022 + +# --- Fayda note -------------------------------------------------------------- +# There is nothing to configure here for Fayda. The portal serves the callback +# page at /callback and /signup/fayda/callback, and whichever path is registered +# with Fayda must match the API's FAYDA_REDIRECT_URI exactly. +# +# The value being registered first is http://localhost:3001/callback, so the +# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing +# extra to run — `nx serve portal` already binds the right port. diff --git a/.gitignore b/.gitignore index 4b15bf545..8b70b3e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ coverage/ # env .env .env.* +# ...but the checked-in template must survive that rule. +!.env.example # logs *.log diff --git a/README.md b/README.md index 361b9afd8..9b3ae76c1 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,20 @@ A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and ## Tech Stack -| Tool | Version | -|------|---------| -| React | 19 | -| Nx | 22 | -| Vite | 7 | -| TypeScript | 5.9 | -| Redux Toolkit | 2.11 | -| Mantine | 8.3 | -| React Router | 7 | -| TanStack Query | 5 | -| React Hook Form | 7 | -| Zod | 4 | -| Tailwind CSS | 3.4 | -| Vitest | 4 | +| Tool | Version | +| --------------- | ------- | +| React | 19 | +| Nx | 22 | +| Vite | 7 | +| TypeScript | 5.9 | +| Redux Toolkit | 2.11 | +| Mantine | 8.3 | +| React Router | 7 | +| TanStack Query | 5 | +| React Hook Form | 7 | +| Zod | 4 | +| Tailwind CSS | 3.4 | +| Vitest | 4 | --- @@ -37,16 +37,19 @@ emaui/ ``` ### libs/api + - `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage. - `session/` — `resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback. - `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file. ### libs/ui + - `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation. - `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects. - `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers. ### libs/shared + - `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale. --- @@ -86,14 +89,14 @@ npm run dev:all ## Environment Variables -| Variable | Required | Default | Description | -|---|---|---|---| -| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests | -| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools | -| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it | -| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret | -| `PORTAL_PORT` | No | `4200` | Docker host port for portal | -| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice | +| Variable | Required | Default | Description | +| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests | +| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools | +| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it | +| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret | +| `PORTAL_PORT` | No | `4200` | Docker host port for portal | +| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice | --- @@ -112,6 +115,7 @@ The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoff ## Adding a New Feature 1. Create the feature folder under the relevant app: + ``` apps/backoffice/src/app/features// ├── types/ # TypeScript interfaces diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index e9f4d2892..115ea32e6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -1,9 +1,9 @@ import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api'; +import { BASE_API_URL } from '@ema-platform/api'; + /** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */ -export const API_BASE_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; +export const API_BASE_URL = BASE_API_URL; export const STATUS_COLOR: Record = { DRAFT: 'gray', diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index d313a38ac..4dbe8f713 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -12,6 +12,7 @@ import type { CreateIncidentPayload, ResolveIncidentPayload, RegradeOutcome, + GradingSheet, } from '../types/exam'; const examApi = baseApi.injectEndpoints({ @@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */ + getGradingSheet: builder.query< + GradingSheet, + { examId: string; profileId: string } + >({ + query: ({ examId, profileId }) => + `/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`, + providesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -119,4 +129,5 @@ export const { useRecordIncidentMutation, useResolveIncidentMutation, useRegradeAttemptMutation, + useGetGradingSheetQuery, } = examApi; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index a17673197..ff8759458 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -22,6 +22,7 @@ import { TextInput, ThemeIcon, Box, + Tooltip, rem, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; @@ -48,6 +49,7 @@ import { useUpdateExamMutation, useAssignQuestionsMutation, useSelectRandomQuestionsMutation, + useGetExamRegistrationsQuery, } from '../api/exam-api'; import { useGetQuestionsQuery } from '../../question/api/question-api'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; @@ -114,6 +116,12 @@ export function ExamDetailPage() { const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation(); const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id }); + // The backend locks the paper the moment the first candidate registers + // (ExamService.assertPaperEditable) — every candidate must sit the same + // paper. Same query ExamCandidatesPanel already runs, so RTK Query serves + // it from cache rather than issuing a second request. + const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id }); + const paperLocked = (registrations?.length ?? 0) > 0; const { data: qRes } = useGetQuestionsQuery(); const { data: certRes } = useGetCertificationsQuery(); const allQuestions = qRes?.items ?? []; @@ -122,10 +130,14 @@ export function ExamDetailPage() { // Only approved bank items may go on a paper (US-EXAM-003), so the picker // must not offer drafts or retired questions either. // - // BOTH describes a mixed paper — a question itself is never "BOTH" (see + // Filters on exam.form alone, not administrationMethod: the backend no + // longer restricts ONLINE to CHOICE (ExamService no longer has an + // assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of + // truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH + // describes a mixed paper — a question itself is never "BOTH" (see // QuestionForm), so an equality check against it would match nothing and - // silently offer zero questions. Same skip-condition as the backend's own - // random draw (ExamService.selectRandomQuestions). + // silently offer zero questions; skipped the same way the backend's own + // random draw does (ExamService.selectRandomQuestions). const eligibleQuestions = useMemo(() => { if (!exam) return []; return allQuestions @@ -179,7 +191,9 @@ export function ExamDetailPage() { } catch (error) { const key = extractErrorMessage(error, t('exam.randomError')); notify.error( - key.startsWith('insufficient_approved_questions') + key === 'paper_locked_after_registration' + ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 }) + : key.startsWith('insufficient_approved_questions') ? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})` : key.startsWith('paper_cannot_reach_cutting_point') ? t('exam.cannotReachCuttingPoint', { @@ -200,7 +214,9 @@ export function ExamDetailPage() { } catch (error) { const key = extractErrorMessage(error, 'Failed to assign questions'); notify.error( - key.startsWith('question_not_approved') + key === 'paper_locked_after_registration' + ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 }) + : key.startsWith('question_not_approved') ? t('question.qc.onlyApprovedUsable') : key.startsWith('paper_cannot_reach_cutting_point') ? t('exam.cannotReachCuttingPoint', { @@ -452,19 +468,45 @@ export function ExamDetailPage() { {t("exam.detail.questionsSection", { pts: totalPoints })} - + + {paperLocked && ( + + {t("exam.paperLocked")} + + )} + + {/* Wrapped: a disabled Mantine Button fires no pointer events, + so the tooltip needs an enabled element to hang off. */} + + + + + {(exam.questions ?? []).length === 0 ? ( - }> - {t("exam.noQuestionsAssigned")} + } + > + {paperLocked + ? t("exam.paperLockedHint", { count: registrations?.length ?? 0 }) + : t("exam.noQuestionsAssigned")} ) : ( diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index 874526c70..c4578d308 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -79,21 +79,44 @@ function ExamForm({ const [status, setStatus] = useState(editing?.status ?? null); const [activeTab, setActiveTab] = useState("basic"); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + /** + * Split per tab so "Next" can check just the tab in front of the user. + * Submitting from Basic Info used to complain about Settings fields the + * user had not been shown yet — the error was correct and unactionable at + * the same time. Each returns the message key for what is missing, or null. + */ + const validateBasic = (): string | null => { if (!certificationId || !titleEn || !titleAm || !date || !venue) { - setActiveTab("basic"); - notify.error(t("exam.form.fillRequiredBasic")); - return; + return "exam.form.fillRequiredBasic"; } if ((directionEn || directionAm) && !(directionEn && directionAm)) { - setActiveTab("basic"); - notify.error(t("exam.form.directionBothLanguages")); + return "exam.form.directionBothLanguages"; + } + return null; + }; + + const validateSettings = (): string | null => + !type || !form || !adminMethod || !evalMethod || !cuttingPoint + ? "exam.form.fillRequiredSettings" + : null; + + const goNext = () => { + const error = validateBasic(); + if (error) { + notify.error(t(error)); return; } - if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) { - setActiveTab("settings"); - notify.error(t("exam.form.fillRequiredSettings")); + setActiveTab("settings"); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // Still checks both: the tabs are clickable, so a user can reach Settings + // without going through Next. + const error = validateBasic() ?? validateSettings(); + if (error) { + setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic"); + notify.error(t(error)); return; } onSubmit( @@ -255,12 +278,6 @@ function ExamForm({ onChange={setForm} size="sm" required - disabled={adminMethod === "ONLINE"} - description={ - adminMethod === "ONLINE" - ? t("exam.form.onlineChoiceOnlyHint") - : undefined - } />