From 9f8f29d61478384d56ac530cc1ab6325a4157782 Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 24 Aug 2026 11:30:56 +0000 Subject: [PATCH 01/31] fix(exam): stop hiding non-CHOICE questions from OFFLINE assignment picker eligibleQuestions filtered by q.form === exam.form, which matched the backend's own restriction (assertUsable) only for ONLINE exams. Once exam.form gained the "BOTH" value for OFFLINE mixed papers, no question ever has form "BOTH", so the picker silently emptied out or, for a plain CHOICE-form exam, looked CHOICE-only regardless of administration method. Now mirrors the backend: CHOICE-only gate applies only when administrationMethod is ONLINE. --- .../src/app/features/exam/pages/ExamDetailPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 8e9037840..5ce7a4ab5 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -121,18 +121,25 @@ 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. + // + // Form restriction mirrors the backend's assertUsable: ONLINE exams are + // CHOICE-only (auto-grading needs it), OFFLINE exams have no form + // restriction at all — mixing ESSAY and CHOICE by hand (exam.form + // "BOTH") is the sanctioned, only way to build a mixed paper. Matching + // `q.form === exam.form` here used to hide every question once "BOTH" + // became a real exam.form value, since no question itself is "BOTH". const eligibleQuestions = useMemo(() => { if (!exam) return []; return allQuestions .filter( (q) => q.certificationId === exam.certificationId && - q.form === exam.form && - q.status === 'APPROVED', + q.status === 'APPROVED' && + (exam.administrationMethod !== 'ONLINE' || q.form === 'CHOICE'), ) .map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points })); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allQuestions, exam?.certificationId, exam?.form]); + }, [allQuestions, exam?.certificationId, exam?.administrationMethod]); if (isLoading) return ; From 35961ec8099a9712254545a2cbbc56ea92581023 Mon Sep 17 00:00:00 2001 From: mihretue Date: Mon, 24 Aug 2026 12:57:37 +0000 Subject: [PATCH 02/31] feat(exam): let ONLINE exams take ESSAY/BOTH form, add grading-sheet API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of the backend change: ExamPage no longer disables the form Select or force-resets it to CHOICE when administrationMethod is ONLINE (backend's assertOnlineIsChoiceOnly is gone, matching gate removed here). Also wires up GET .../grading-sheet as useGetGradingSheetQuery — RecordResultModal wiring (show candidate answers, prefill auto-computed CHOICE scores) is next. --- .../src/app/features/exam/api/exam-api.ts | 11 +++++++++++ .../app/features/exam/pages/ExamPage/index.tsx | 15 +-------------- .../src/app/features/exam/types/exam.ts | 18 ++++++++++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 1 - apps/backoffice/src/app/i18n/locales/en.ts | 1 - 5 files changed, 30 insertions(+), 16 deletions(-) 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/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index 034d203ae..34039328c 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -255,12 +255,6 @@ function ExamForm({ onChange={setForm} size="sm" required - disabled={adminMethod === "ONLINE"} - description={ - adminMethod === "ONLINE" - ? t("exam.form.onlineChoiceOnlyHint") - : undefined - } /> setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} /> + setDeviceId(e.currentTarget.value)} w={180} /> + + + + + + + On file + + + {isLoading ? ( + + ) : ( + + {MODALITIES.map((m) => ( + + + + + + {m.label} + + {hasActive(m.value) ? ( + + + + + ) : ( + Not enrolled + )} + + ))} + {(enrollments ?? []).map((e) => ( + + {e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''} + + ))} + + )} + + + )} + + ); +} + +export default BiometricEnrollmentPage; diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 94622fa47..672c771a1 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -93,6 +93,7 @@ export const am: Translations = { // vesselRegistrationReport: 'የምዝገባ ሪፖርት', vesselTransfers: "የመርከብ ባለቤትነት ዝውውር", seafarerRegistry: "የመርከበኞች መዝገብ", + biometricEnrollment: "ባዮሜትሪክ ምዝገባ", seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ", applications: "ማመልከቻዎች", paymentConfig: "የክፍያ ውቅረት", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index e98459f58..197ff06f8 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -92,6 +92,7 @@ export const en = { vesselRegistrations: 'Vessel Registration', vesselTransfers: 'Vessel Ownership Transfer', seafarerRegistry: 'Seafarer Registry', + biometricEnrollment: 'Biometric Enrollment', seafarerRegistrationQueue: 'Seafarer Registration Queue', applications: 'Applications', paymentConfig: 'Payment Config', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index 969e7d0f9..3122c22a2 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -8,6 +8,7 @@ import { IconCreditCard, IconFileDescription, IconFilePlus, + IconFingerprint, IconGauge, IconGavel, IconHeart, @@ -96,6 +97,7 @@ export const NAV_SECTIONS: NavSection[] = [ label: 'nav.groupSeafarer', items: [ { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] }, + { to: '/biometric-enrollment', label: 'nav.biometricEnrollment', icon: IconFingerprint, permissions: [P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS] }, { to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: [P.VIEW_SEAFARER_REGISTRY] }, { to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] }, { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] }, diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index dde6d1e2e..46f0879f0 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -48,6 +48,7 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage'; import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage'; import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage'; +import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/BiometricEnrollmentPage'; /** Any-of gate shared by every licence-type queue and its review workspace. */ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS]; @@ -100,6 +101,7 @@ const router = createBrowserRouter([ { path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, { path: 'payment-config', element: guard([P.VIEW_PAYMENTS], ) }, { path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], ) }, + { path: 'biometric-enrollment', element: guard([P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS], ) }, // Seafarer registration is not a licence: own queue, own review. { path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, ) }, { path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, ) }, diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index 903012532..558be9a7a 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/features/location'; export * from './lib/features/seafarer'; export * from './lib/features/seafarer-registration'; export * from './lib/features/seafarer-document'; +export * from './lib/features/biometric-enrollment'; export * from './lib/features/vessel'; export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download'; diff --git a/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts new file mode 100644 index 000000000..fd10cec4f --- /dev/null +++ b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts @@ -0,0 +1,44 @@ +import { baseApi } from '../../base-api'; +import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types'; + +const TAG = 'BiometricEnrollment' as const; +const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const; + +/** + * Scanner capture (fingerprint, face) stored per profile. No applicant-facing + * endpoint — enrollment happens at a counter with a scanner. + */ +export const biometricEnrollmentApi = baseApi + .enhanceEndpoints({ addTagTypes: [TAG] }) + .injectEndpoints({ + endpoints: (builder) => ({ + enrollBiometric: builder.mutation({ + query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }), + invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]), + }), + + getBiometricEnrollments: builder.query({ + query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }), + providesTags: (_r, _e, profileId) => [forProfile(profileId)], + }), + + revokeBiometricEnrollment: builder.mutation< + BiometricEnrollment, + { id: string; profileId: string; reason: string } + >({ + query: ({ id, reason }) => ({ + url: `/biometric-enrollments/${id}/revoke`, + method: 'POST', + body: { reason }, + }), + invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]), + }), + }), + overrideExisting: false, + }); + +export const { + useEnrollBiometricMutation, + useGetBiometricEnrollmentsQuery, + useRevokeBiometricEnrollmentMutation, +} = biometricEnrollmentApi; diff --git a/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts new file mode 100644 index 000000000..d70322672 --- /dev/null +++ b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts @@ -0,0 +1,32 @@ +export type BiometricModality = 'FINGERPRINT' | 'FACE'; +export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED'; + +export interface BiometricEnrollment { + id: string; + profileId: string; + modality: BiometricModality; + templateFormat: string; + qualityScore: number | null; + deviceId: string | null; + status: BiometricEnrollmentStatus; + enrolledById: string; + enrolledAt: string; + consentAt: string; + revokedReason: string | null; + revokedById: string | null; + revokedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface EnrollBiometric { + profileId: string; + modality: BiometricModality; + /** Vendor SDK template, base64. Never the raw scan image. */ + template: string; + templateFormat: string; + qualityScore?: number; + deviceId?: string; + /** ISO 8601 — when the subject consented to capture. */ + consentAt: string; +} diff --git a/libs/api/src/lib/features/biometric-enrollment/index.ts b/libs/api/src/lib/features/biometric-enrollment/index.ts new file mode 100644 index 000000000..f824d5e0a --- /dev/null +++ b/libs/api/src/lib/features/biometric-enrollment/index.ts @@ -0,0 +1,2 @@ +export * from './biometric-enrollment.types'; +export * from './biometric-enrollment-api'; diff --git a/libs/auth/src/lib/permissions.constants.ts b/libs/auth/src/lib/permissions.constants.ts index 3ea7859c8..b09917649 100644 --- a/libs/auth/src/lib/permissions.constants.ts +++ b/libs/auth/src/lib/permissions.constants.ts @@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = { MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status", VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry", VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records", + ENROLL_BIOMETRICS: "can:enroll:biometrics", + VIEW_BIOMETRICS: "can:View:biometrics", VIEW_VESSEL_REGISTRY: "can:View:vessel-registry", MANAGE_VESSEL_STATUS: "can:manage:vessel-status", APPROVE_QUESTION: "can:approve:exam-question", From 51627f991864385052f94071c208f272208b7b31 Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 06:46:06 +0000 Subject: [PATCH 20/31] feat: add inspection rescheduling workflow and unlock dependencies for flagged document sections --- .../features/license-review/config/actions.ts | 18 ++ .../pages/LicenseReviewPage/index.tsx | 198 +++++++++++++----- apps/backoffice/src/app/i18n/locales/am.ts | 5 + apps/backoffice/src/app/i18n/locales/en.ts | 5 + apps/backoffice/vite.config.mts | 20 +- .../licensing/components/DocumentSlots.tsx | 13 +- .../pages/LicenseApplicationPage.tsx | 31 ++- .../lib/features/licensing/licensing-api.ts | 25 +++ .../features/licensing/licensing.helpers.ts | 43 ++++ 9 files changed, 295 insertions(+), 63 deletions(-) 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 8511b7b29..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', @@ -441,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 3c5b8f2f1..cfb2bf6ee 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 [resultOpen, setResultOpen] = useState(false); @@ -311,10 +317,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 } = @@ -558,12 +568,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; @@ -1246,21 +1273,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, + )} + + ))} @@ -1430,7 +1484,11 @@ export function LicenseReviewPage() { setInspectionOpen(false)} - title={t("review.actions.scheduleInspection", "Schedule inspection")} + title={ + rescheduling + ? t("review.actions.rescheduleInspection", "Reschedule inspection") + : t("review.actions.scheduleInspection", "Schedule inspection") + } > + {rescheduling && ( +