From def6218dbfe52db4155f09b7667dd40b1a10daef Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 27 Aug 2026 07:50:43 +0000 Subject: [PATCH 01/16] fix: show inspection-failed alert at page level, not inside the inspection tab A license type configured without an inspection detail section (e.g. vessel ownership transfer) would hide the "approval blocked" explanation entirely if it ever reached INSPECTION_FAILED. The alert now renders beside the other page-level status notices, so the reason is visible regardless of which detail sections the type declares. Co-Authored-By: Claude Fable 5 --- .../pages/LicenseReviewPage/index.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) 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 71a13403a..23311a189 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 @@ -1077,18 +1077,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 ? ( @@ -1136,6 +1124,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" && ( Date: Thu, 27 Aug 2026 08:16:20 +0000 Subject: [PATCH 02/16] style: update component background and icon colors to use semantic theme variables --- .../certificate-requirements/components/ConditionBuilder.tsx | 2 +- .../components/DocumentRequirementsTab.tsx | 2 +- .../certificate-requirements/components/FormSchemaTab.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx index b1192fca2..7c016bd76 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx @@ -198,7 +198,7 @@ function ConditionArmFields({ fz="xs" px={6} py={2} - bg="var(--mantine-color-gray-1)" + bg="var(--mantine-color-default-hover)" style={{ borderRadius: 4, cursor: 'pointer' }} onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))} title={t('certReq.condition.removeValue', 'Click to remove')} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx index 05680009d..033795186 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx @@ -127,7 +127,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT ) : ( {rows.map((req) => ( - +
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx index ddeb712f1..f103b43d4 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx @@ -209,7 +209,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) { - +
{localized(section.title) || section.key} @@ -237,7 +237,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) { {section.fields.map((field, fIndex) => ( - +
From 88a1caa52b6dea89e1c0761e457380a16e0af14d Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 27 Aug 2026 12:29:54 +0000 Subject: [PATCH 03/16] feat: add sorting support to seafarer registration query and default queue to newest first --- .../pages/SeafarerRegistrationQueuePage.tsx | 4 +++- .../seafarer-registration/seafarer-registration-api.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx index 4a3787697..7c978f38d 100644 --- a/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage.tsx @@ -26,7 +26,7 @@ export function applicantName(r: Pick ({ type: TAG, id }) as const; +export type SeafarerRegistrationSortField = + | 'submittedAt' + | 'registrationNumber' + | 'lastName' + | 'status'; + export interface SeafarerRegistrationListFilter { status?: SeafarerRegistrationStatus; search?: string; + sortBy?: SeafarerRegistrationSortField; + sortDir?: 'ASC' | 'DESC'; take?: number; skip?: number; } From e86375e7619908f20f367753802287e32742b2c4 Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 27 Aug 2026 12:37:04 +0000 Subject: [PATCH 04/16] refactor: update application locking logic to handle staff remarks and itemized rounds --- .../pages/LicenseApplicationPage.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 30b801ab3..ac497f5f7 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -352,14 +352,18 @@ export function LicenseApplicationPage() { ), [roundRemarks], ); - const hasSectionRemarks = Object.keys(flaggedSections).length > 0; - const hasDocRemarks = Object.keys(flaggedDocuments).length > 0; + const hasStaffRemarks = roundRemarks.some((r) => r.targetType === "STAFF"); + // Nothing at all came back for this round (a detail response that predates + // the remarks, say) — lock nothing rather than freeze the whole application + // with no way forward. Any remark present means the round is itemised, so + // only what the officer flagged opens: a documents-only round leaves every + // form section frozen, and a sections-only round leaves every document as + // filed. + const roundIsItemised = isAdjusting && roundRemarks.length > 0; - // A round that flagged no form sections carries no section locks — mirror of - // the server's fallback, without which a documents-only correction round - // froze every field and the applicant could not edit anything at all. const isSectionLocked = (sectionKey: string) => - isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey]; + roundIsItemised && !flaggedSections[sectionKey]; + const staffLocked = roundIsItemised && !hasStaffRemarks; // Sections that share a group collapse onto one step, so the stepper stays // short instead of showing a page per section. @@ -862,7 +866,7 @@ export function LicenseApplicationPage() { complete )} - {!readOnly && ( + {!readOnly && !staffLocked && (
- {!readOnly && ( + {!readOnly && !staffLocked && (
@@ -937,7 +941,7 @@ export function LicenseApplicationPage() { ownerType="APPLICATION" ownerId={appId} flagged={flaggedDocuments} - restrictToFlagged={isAdjusting && hasDocRemarks} + restrictToFlagged={roundIsItemised} readOnly={readOnly} onUploaded={() => { refetchAttachments(); From 822abe4de40c8e6a6aef3cb04afba2253bc00e5f Mon Sep 17 00:00:00 2001 From: Nati Date: Fri, 28 Aug 2026 06:45:47 +0000 Subject: [PATCH 05/16] feat(biometric-enrollment): add biometric enrollment feature with API integration and UI components --- .../pages/BiometricEnrollmentPage.tsx | 253 ++++++++++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 1 + apps/backoffice/src/app/i18n/locales/en.ts | 1 + apps/backoffice/src/app/layouts/nav-config.ts | 2 + apps/backoffice/src/app/router/index.tsx | 2 + libs/api/src/index.ts | 1 + .../biometric-enrollment-api.ts | 44 +++ .../biometric-enrollment.types.ts | 32 +++ .../features/biometric-enrollment/index.ts | 2 + libs/auth/src/lib/permissions.constants.ts | 2 + 10 files changed, 340 insertions(+) create mode 100644 apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx create mode 100644 libs/api/src/lib/features/biometric-enrollment/biometric-enrollment-api.ts create mode 100644 libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts create mode 100644 libs/api/src/lib/features/biometric-enrollment/index.ts 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..87f56fbfa --- /dev/null +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -0,0 +1,253 @@ +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, + useGetBiometricEnrollmentsQuery, + 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 }); + 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 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 ? ( + + ) : ( + + + +
+ {applicantName(selected)} + {selected.seafarerNumber} +
+ +
+
+ + + Capture + } mb="sm" variant="light"> + No scanner is wired yet — this simulates a capture so the rest of the flow can be tested. + + +