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/biometric-enrollment/pages/BiometricEnrollmentPage.tsx b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx new file mode 100644 index 000000000..5ab70f38e --- /dev/null +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -0,0 +1,322 @@ +import { useMemo, useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Container, + Group, + Loader, + Select, + Stack, + Table, + Text, + TextInput, + ThemeIcon, +} from '@mantine/core'; +import { IconAlertTriangle, IconFingerprint, IconPrinter, IconScan, IconSearch, IconX } from '@tabler/icons-react'; +import { useDebouncedValue } from '@mantine/hooks'; +import { + extractErrorMessage, + openAuthedDocument, + useEnrollBiometricMutation, + useGenerateBsidMutation, + useGetBiometricEnrollmentsQuery, + useGetBiometricSimulateCapabilitiesQuery, + useListSeafarerRegistrationsQuery, + useRevokeBiometricEnrollmentMutation, + type BiometricModality, + type SeafarerRegistration, +} from '@ema-platform/api'; +import { notify, PageHeader, StatusBadge } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; + +const MODALITIES: { value: BiometricModality; label: string }[] = [ + { value: 'FINGERPRINT', label: 'Fingerprint' }, + { value: 'FACE', label: 'Face' }, +]; + +function applicantName(r: Pick): string { + return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—'; +} + +/** + * No scanner is wired yet (US-BIO placeholder): "Simulate Scan" stands in for + * the real vendor SDK capture, producing a random template so the rest of the + * pipeline — encrypt, store, print — is exercisable end to end. Swap the + * simulated bytes for the SDK's real template once a vendor is chosen; the + * API call shape (base64 template + format tag) does not change. + */ +function fakeTemplate(): string { + const bytes = crypto.getRandomValues(new Uint8Array(64)); + return btoa(String.fromCharCode(...bytes)); +} + +/** Pick a registered seafarer to enroll — approved registrations carry a profileId. */ +function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) { + const [search, setSearch] = useState(''); + const [debounced] = useDebouncedValue(search, 300); + const { data, isFetching } = useListSeafarerRegistrationsQuery({ + status: 'APPROVED', + search: debounced || undefined, + take: 10, + }); + + return ( + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + mb="sm" + /> + {isFetching && } + + + {(data?.items ?? []).map((r) => ( + onPick(r)} style={{ cursor: 'pointer' }}> + + {applicantName(r)} + {r.seafarerNumber} + + + ))} + {!isFetching && (data?.items ?? []).length === 0 && ( + + + No registered seafarer matches. + + + )} + +
+
+ ); +} + +/** Backoffice counter screen: enroll a scanner capture against a profile, view what's on file, print the slip. */ +export function BiometricEnrollmentPage() { + const showDate = useDateDisplayer(); + const [selected, setSelected] = useState(null); + const [modality, setModality] = useState('FINGERPRINT'); + const [deviceId, setDeviceId] = useState(''); + + const profileId = selected?.profileId ?? ''; + const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId }); + // No vendor SDK integrated yet — "Simulate Scan" fakes a capture so the + // rest of the flow is exercisable. Reports false in production unless + // ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses. + const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery(); + const simulateEnabled = capabilities?.simulateEnabled ?? false; + const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation(); + // Seeded from the seafarer registration list (which does not carry BSID + // yet) and updated locally once generated — this screen's only source of + // truth for it until the registry surfaces the profile's BSID directly. + const [bsid, setBsid] = useState(null); + const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation(); + const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation(); + const [printing, setPrinting] = useState(false); + + const hasActive = useMemo( + () => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m), + [enrollments], + ); + + async function handleEnroll() { + if (!profileId) return; + try { + await enroll({ + profileId, + modality, + template: fakeTemplate(), + templateFormat: 'SIMULATED', + deviceId: deviceId || undefined, + consentAt: new Date().toISOString(), + }).unwrap(); + notify.success(`${modality === 'FINGERPRINT' ? 'Fingerprint' : 'Face'} enrolled.`); + } catch (err) { + notify.error(extractErrorMessage(err, 'Enrollment failed.')); + } + } + + async function handleGenerateBsid() { + if (!profileId) return; + try { + const result = await generateBsid(profileId).unwrap(); + setBsid(result.bsid); + notify.success(`BSID ${result.bsid} generated.`); + } catch (err) { + notify.error(extractErrorMessage(err, 'Could not generate BSID.')); + } + } + + async function handleRevoke(id: string) { + if (!profileId) return; + try { + await revoke({ id, profileId, reason: 'Withdrawn at counter' }).unwrap(); + notify.success('Enrollment revoked.'); + } catch (err) { + notify.error(extractErrorMessage(err, 'Could not revoke.')); + } + } + + async function handlePrint() { + if (!profileId) return; + setPrinting(true); + try { + await openAuthedDocument( + `/biometric-enrollments/profile/${profileId}/certificate`, + `biometric-enrollment-${profileId}.pdf`, + ); + } catch (err) { + notify.error(extractErrorMessage(err, 'Could not open the certificate.')); + } finally { + setPrinting(false); + } + } + + return ( + + + + {!selected ? ( + { + setSelected(r); + setBsid(null); + }} + /> + ) : ( + + + +
+ {applicantName(selected)} + {selected.seafarerNumber} +
+ +
+
+ + + Capture + {simulateEnabled ? ( + <> + } mb="sm" variant="light"> + No scanner is wired yet — this simulates a capture so the rest of the flow can be tested. + + + { - setAdminMethod(value); - // Online exams are graded automatically, and that only - // has an answer model for CHOICE — matches the backend - // rule (online_exam_requires_choice_form), not just a - // UI nicety. - if (value === "ONLINE") setForm("CHOICE"); - }} + onChange={setAdminMethod} size="sm" required /> @@ -350,9 +360,26 @@ function ExamForm({ - + {activeTab === "basic" ? ( + /* Not type="submit": Basic Info is not the last step, so the + primary action advances rather than saves. */ + + ) : ( + <> + + + + )} diff --git a/apps/backoffice/src/app/features/exam/types/exam.ts b/apps/backoffice/src/app/features/exam/types/exam.ts index a66fed338..52adbd6fa 100644 --- a/apps/backoffice/src/app/features/exam/types/exam.ts +++ b/apps/backoffice/src/app/features/exam/types/exam.ts @@ -133,6 +133,24 @@ export interface ExamRegistration { export type RegradeOutcome = { graded: true; resultId: string } | { graded: false; reason: string }; +/** One question's row on the staff grading sheet — the candidate's own + * answer plus the auto-computable score, where one exists. */ +export interface GradingSheetQuestion { + questionId: string; + form: QuestionForm; + points: number; + answerText: string | null; + selectedOptionId: string | null; + selectedOptionText: { en?: string; am?: string } | null; + /** null means "no auto-score" — examiner enters one by hand. */ + autoScore: number | null; +} + +export interface GradingSheet { + attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED'; + questions: GradingSheetQuestion[]; +} + export interface RecordAttendancePayload { registrationId: string; status: AttendanceStatus; diff --git a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx index 641ffc864..320f5a6e2 100644 --- a/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx +++ b/apps/backoffice/src/app/features/license-review/components/ScheduleExamModal.tsx @@ -22,6 +22,9 @@ interface Props { * of a per-candidate appointment. Scoped to sittings whose certification * matches this application's rank, so a Chief Mate candidate cannot be seated * into an OOW Deck sitting by accident. + * + * Scheduling makes the sitting available; it does not register the candidate. + * That is their own act, from the portal's Register button. */ export function ScheduleExamModal({ opened, @@ -61,7 +64,7 @@ export function ScheduleExamModal({ {t('review.scheduleExam.intro', { defaultValue: - 'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.', + 'Make a sitting available to {{applicant}}. They register for it themselves from the portal.', applicant: applicantName, })} @@ -89,7 +92,7 @@ export function ScheduleExamModal({ {t( 'review.scheduleExam.admissionHint', - 'An admission number is issued automatically when the candidate is seated.', + 'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.', )} diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 475c7fb9b..a5ac52faa 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -28,6 +28,7 @@ export type ActionId = | 'complete-review' | 'approve-documents' | 'schedule-inspection' + | 'reschedule-inspection' | 'record-inspection' | 'final-approve' | 'request-adjustment' @@ -200,6 +201,17 @@ export const ACTIONS: ActionDefinition[] = [ permissions: ['can:create:inspection'], emphasis: 'filled', }, + { + id: 'reschedule-inspection', + tier: 'primary', + labelKey: 'review.actions.rescheduleInspection', + from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'], + // Either permission: the team leader who booked the visit holds CREATE, + // the inspector who has to attend holds UPDATE, and both have a reason to + // move it. The server guards the same pair. + permissions: ['can:create:inspection', 'can:update:inspection'], + emphasis: 'light', + }, { id: 'record-inspection', tier: 'primary', @@ -212,12 +224,15 @@ export const ACTIONS: ActionDefinition[] = [ id: 'final-approve', tier: 'primary', labelKey: 'review.actions.finalApprove', - from: ['INSPECTION_COMPLETED', - 'REVIEW_REPORTED', - 'INSPECTION_REPORTED', 'UNDER_EVALUATION'], // ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight // off the eligibility queue — no assignment step. - from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION', 'ELIGIBILITY_PAID'], + from: [ + 'INSPECTION_COMPLETED', + 'REVIEW_REPORTED', + 'INSPECTION_REPORTED', + 'UNDER_EVALUATION', + 'ELIGIBILITY_PAID', + ], permissions: ['can:approve:license-application'], emphasis: 'filled', color: 'teal', @@ -227,13 +242,12 @@ export const ACTIONS: ActionDefinition[] = [ id: 'request-adjustment', tier: 'primary', labelKey: 'review.actions.requestAdjustment', - from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED', - 'REVIEW_REPORTED', - 'INSPECTION_REPORTED'], from: [ 'UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED', + 'REVIEW_REPORTED', + 'INSPECTION_REPORTED', 'INSPECTION_FAILED', 'ELIGIBILITY_PAID', ], @@ -439,6 +453,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { // one applies depends on whether an inspection is already booked. if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return []; if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return []; + // The mirror of the scheduling gate: there is nothing to move until a + // visit is booked, and once one is, moving it is the officer's only + // option until the day arrives. + if (action.id === 'reschedule-inspection' && !ctx.hasPendingInspection) { + return []; + } // The transition table doesn't know which types need an inspection, so // `availableEvents` lists approve-documents at UNDER_EVALUATION even for diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 52d8e2443..fb2795287 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -32,6 +32,7 @@ import { IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand, IconPaperclip, + IconPencil, IconQuestionMark, IconX, } from "@tabler/icons-react"; @@ -70,6 +71,7 @@ import { useRequestAdjustmentMutation, useResumeApplicationMutation, useScheduleInspectionMutation, + useRescheduleInspectionMutation, useGetCertificateUrlForOfficerMutation, uploadDocument, type RemarkTargetType, @@ -218,6 +220,7 @@ export function LicenseReviewPage() { const [finalApprove] = useFinalApproveMutation(); const [rejectApplication] = useRejectApplicationMutation(); const [scheduleInspection] = useScheduleInspectionMutation(); + const [rescheduleInspection] = useRescheduleInspectionMutation(); const [recordResult] = useRecordInspectionResultMutation(); const [confirmPayment] = useConfirmPaymentMutation(); const [scheduleIssuance] = useScheduleIssuanceMutation(); @@ -261,6 +264,9 @@ export function LicenseReviewPage() { const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">( "MORNING", ); + /** The booking modal moves an existing visit rather than creating one. */ + const [rescheduling, setRescheduling] = useState(false); + const [rescheduleReason, setRescheduleReason] = useState(""); const [issuanceOpen, setIssuanceOpen] = useState(false); const [issuanceDate, setIssuanceDate] = useState(""); const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">( @@ -314,10 +320,14 @@ export function LicenseReviewPage() { const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED'); // A visit cannot have an outcome before it happens — mirror of the server's - // inspection_not_yet_due guard, compared instant-to-instant. + // inspection_not_yet_due guard. The column holds a calendar day, so the + // comparison is between day strings in the authority's timezone: parsing + // "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis. const inspectionNotYetDue = Boolean( pendingInspection?.scheduledDate && - new Date(pendingInspection.scheduledDate) > new Date(), + new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format( + new Date(), + ) < pendingInspection.scheduledDate.slice(0, 10), ); const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } = @@ -561,12 +571,29 @@ export function LicenseReviewPage() { } } + /** Opens the booking modal seeded with the visit already on the books. */ + function openReschedule() { + if (!pendingInspection) return; + setRescheduling(true); + setInspectionDate(pendingInspection.scheduledDate ?? ""); + setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING"); + setRescheduleReason(""); + setInspectionOpen(true); + } + /** Actions with their own dedicated form open that; the rest confirm. */ function handleAction(action: ResolvedAction) { switch (action.id) { case "schedule-inspection": + setRescheduling(false); + setInspectionDate(""); + setInspectionTimeSlot("MORNING"); + setRescheduleReason(""); setInspectionOpen(true); return; + case "reschedule-inspection": + openReschedule(); + return; case "schedule-issuance": setIssuanceOpen(true); return; @@ -1222,18 +1249,6 @@ export function LicenseReviewPage() { - {status === "INSPECTION_FAILED" && ( - } - > - {t( - "review.inspectionFailedBlocked", - "Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.", - )} - - )} {inspections.length === 0 ? ( @@ -1266,21 +1281,48 @@ export function LicenseReviewPage() { )} - - {inspection.result === "PASSED" - ? t("review.passed", "Passed") - : inspection.result === "FAILED" - ? t("review.failed", "Failed") - : t( - `review.inspectionStatus.${inspection.status}`, - inspection.status, + + {inspection.status === "SCHEDULED" && + inspection.id === pendingInspection?.id && + can([ + "can:create:inspection", + "can:update:inspection", + ]) && ( + + > + + + + + )} + + {inspection.result === "PASSED" + ? t("review.passed", "Passed") + : inspection.result === "FAILED" + ? t("review.failed", "Failed") + : t( + `review.inspectionStatus.${inspection.status}`, + inspection.status, + )} + + ))}
@@ -1289,6 +1331,18 @@ export function LicenseReviewPage() { + {/* Page-level, not inside the inspection tab: a license type + configured without an inspection detail section must still show + why approval is blocked if it ever lands here. */} + {status === "INSPECTION_FAILED" && ( + }> + {t( + "review.inspectionFailedBlocked", + "Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.", + )} + + )} + {status === "PAYMENT_PENDING" && ( setInspectionOpen(false)} - title={t("review.actions.scheduleInspection", "Schedule inspection")} + title={ + rescheduling + ? t("review.actions.rescheduleInspection", "Reschedule inspection") + : t("review.actions.scheduleInspection", "Schedule inspection") + } > + {rescheduling && ( +