diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..367bcf956 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# 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. +# The portal runs on 4200 and the local API runs on 3000. +VITE_BASE_API_URL=http://localhost:3000/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 +# 4200 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. +# +# Register http://localhost:4200/callback with Fayda. 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..4bef421cf 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: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 | --- @@ -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..3577cb379 --- /dev/null +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -0,0 +1,303 @@ +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, IconScan, IconSearch, IconX } from '@tabler/icons-react'; +import { useDebouncedValue } from '@mantine/hooks'; +import { + extractErrorMessage, + 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 seafarer waiting on enrolment. + * + * AWAITING_BIOMETRICS only: enrolment is the step that unblocks the review, so + * this queue is exactly the registrations held for it. An approved seafarer has + * already been through here — listing them would invite a second capture of + * someone who is finished. + */ +function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) { + const [search, setSearch] = useState(''); + const [debounced] = useDebouncedValue(search, 300); + const { data, isFetching } = useListSeafarerRegistrationsQuery({ + status: 'AWAITING_BIOMETRICS', + 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)} + {/* Not seafarerNumber: that is only issued on approval, which + is downstream of this screen, so it is always blank here. */} + {r.registrationNumber} + + + ))} + {!isFetching && (data?.items ?? []).length === 0 && ( + + + No seafarer is waiting on enrolment. + + + )} + +
+
+ ); +} + +/** 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 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.')); + } + } + + return ( + + + + {!selected ? ( + { + setSelected(r); + setBsid(null); + }} + /> + ) : ( + + + +
+ {applicantName(selected)} + {selected.registrationNumber} +
+ +
+
+ + + Capture + {simulateEnabled ? ( + <> + } mb="sm" variant="light"> + No scanner is wired yet — this simulates a capture so the rest of the flow can be tested. + + + v && set('workflowProfile', v as WorkflowProfile)} + allowDeselect={false} + disabled={!canEdit} + /> + + v && set('serviceKind', v as ServiceKind)} + allowDeselect={false} + disabled={!canEdit} + /> + + +
+ + {t( + 'certReq.behavior.eligibilityHint', + 'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', + )} + + + set('capitalThreshold', v)} + disabled={!canEdit} + min={0} + defaultValue={1_000_000} + thousandSeparator + /> + + set('requiresSeafarerRegistration', e.currentTarget.checked)} + label={t('certReq.behavior.requiresSeafarer', 'Requires an active seafarer registration')} + description={t( + 'certReq.behavior.requiresSeafarerHint', + 'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.', + )} + disabled={!canEdit} + /> + + set('requiresValidMedical', e.currentTarget.checked)} + label={t('certReq.behavior.requiresMedical', 'Requires a current medical certificate')} + disabled={!canEdit} + /> + + set('minSeaTimeDays', v)} + disabled={!canEdit} + min={0} + /> + + { + // Switching unit is a change of policy, not a conversion: 12 + // calendar months is not 365 days, so carry no arithmetic across + // and let the administrator state the new term outright. + if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90); + else set('validityDays', null); + }} + allowDeselect={false} + disabled={!canEdit} + w={130} + /> + + + set('renewalEnabled', e.currentTarget.checked)} + label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')} + description={t( + 'certReq.behavior.renewalEnabledHint', + 'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.', + )} + disabled={!canEdit} + /> + + {/* The window and the reminders are both measured against an expiry a + non-renewing licence never reaches, so they are hidden rather than + shown as settings that quietly do nothing. */} + {draft.renewalEnabled && ( + <> + + set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays) + } + min={1} + max={365} + allowNegative={false} + disabled={!canEdit} + /> + + + set( + 'expiryReminderDays', + values.map(Number).sort((a, b) => b - a), + ) + } + disabled={!canEdit} + clearable + /> + + )} +
+ +
+ set('requiresOperatorMode', e.currentTarget.checked)} + label={t('certReq.behavior.requiresOperatorMode', 'Applicant must declare this operating mode')} + description={t( + 'certReq.behavior.requiresOperatorModeHint', + 'Turn off for person-centric registrations any signed-in applicant may start.', + )} + disabled={!canEdit} + /> + + set('allowMultipleOpenDrafts', e.currentTarget.checked)} + label={t('certReq.behavior.allowMultipleDrafts', 'Allow several open drafts at once')} + description={t( + 'certReq.behavior.allowMultipleDraftsHint', + 'On for per-asset registrations — registering a second vessel must not resume the first one’s draft.', + )} + disabled={!canEdit} + /> + + set('inspectionRequired', e.currentTarget.checked)} + label={t('certReq.behavior.inspectionRequired', 'Requires a physical inspection')} + description={t( + 'certReq.behavior.inspectionRequiredHint', + 'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.', + )} + disabled={!canEdit} + /> + + set('requiresIssuanceScheduling', e.currentTarget.checked)} + label={t('certReq.behavior.requiresScheduling', 'Schedule a pickup date before issuing')} + description={t( + 'certReq.behavior.requiresSchedulingHint', + 'For documents printed once and handed over in person.', + )} + disabled={!canEdit} + /> + + set('slaHours', v)} + disabled={!canEdit} + min={1} + description={t( + 'certReq.behavior.slaHint', + 'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.', + )} + /> + + + set('uniqueFormKeyPath', e.currentTarget.value.trim() || null) + } + maxLength={128} + placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')} + disabled={!canEdit} + /> +
+ + + + + + +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + + {title} + + {children} + + + ); +} + +/** + * A number that may be switched off entirely. + * + * Null is a real configuration state here — "not tracked against an SLA", "no + * sea-time floor" — and is not the same as an empty box, so the choice gets its + * own switch rather than being inferred from a blank field. + */ +function NullableNumber({ + label, + switchLabel, + description, + value, + onChange, + disabled, + min, + defaultValue, + thousandSeparator, +}: { + label: string; + switchLabel: string; + description?: string; + value: number | null; + onChange: (value: number | null) => void; + disabled: boolean; + min: number; + /** Seeded when the switch is turned on. Defaults to the minimum. */ + defaultValue?: number; + thousandSeparator?: boolean; +}) { + return ( + + + onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null) + } + label={switchLabel} + description={description} + disabled={disabled} + /> + {value !== null && ( + onChange(typeof v === 'number' ? v : value)} + min={min} + allowNegative={false} + thousandSeparator={thousandSeparator ? ',' : undefined} + decimalScale={thousandSeparator ? 2 : undefined} + disabled={disabled} + /> + )} + + ); +} 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/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx index 1efa584e7..9569913bc 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -6,6 +6,7 @@ import { Drawer, MultiSelect, NumberInput, + SegmentedControl, Select, Stack, Text, @@ -13,33 +14,103 @@ import { } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { BilingualInput, ModalFooter } from '@ema-platform/ui'; -import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api'; +import { + useLocalized, + type ApplicationKind, + type DocumentRequirement, + type FormSchemaPalette, + type LicenseType, +} from '@ema-platform/api'; import { ConditionBuilder, type ConditionValue } from './ConditionBuilder'; import type { ConditionTarget } from '../config/schema-paths'; +/** + * File types a slot may be opened to. + * + * Longer than the three a slot starts with, because what an applicant + * actually has is not always a scan: a phone photographs an ID as HEIC, a + * scanner writes multi-page TIFF, and an academic record often arrives as the + * Word file its institution issued. Widening a slot stays a deliberate choice + * — the defaults below do not change — but it no longer needs a release. + */ const MIME_OPTIONS = [ { value: 'application/pdf', label: 'PDF' }, { value: 'image/jpeg', label: 'JPEG' }, { value: 'image/png', label: 'PNG' }, + { value: 'image/webp', label: 'WebP' }, + { value: 'image/heic', label: 'HEIC (iPhone photo)' }, + { value: 'image/heif', label: 'HEIF' }, + { value: 'image/tiff', label: 'TIFF (scan)' }, + { value: 'image/bmp', label: 'BMP' }, + { value: 'application/msword', label: 'Word (.doc)' }, + { + value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + label: 'Word (.docx)', + }, + { value: 'application/vnd.ms-excel', label: 'Excel (.xls)' }, + { + value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + label: 'Excel (.xlsx)', + }, + { value: 'text/csv', label: 'CSV' }, + { value: 'text/plain', label: 'Plain text (.txt)' }, + // Video is measured in hundreds of megabytes, not the 5 MB a slot starts + // with: raise "Max file size" on any slot that accepts one. + { value: 'video/mp4', label: 'Video (.mp4)' }, + { value: 'video/quicktime', label: 'Video (.mov, iPhone)' }, + { value: 'video/webm', label: 'Video (.webm)' }, + { value: 'video/x-msvideo', label: 'Video (.avi)' }, + { value: 'audio/mpeg', label: 'Audio (.mp3)' }, + { value: 'audio/wav', label: 'Audio (.wav)' }, + // Both, because the same .m4a is reported as audio/mp4 by Chrome and + // audio/x-m4a by Safari; picking one would reject half the recordings. + { value: 'audio/mp4', label: 'Audio (.m4a)' }, + { value: 'audio/x-m4a', label: 'Audio (.m4a, Safari)' }, + { value: 'audio/ogg', label: 'Audio (.ogg)' }, ]; +/** What a new slot accepts until someone widens it. */ +const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; + type DraftRequirement = Omit; -function emptyDraft(applicationKind: ApplicationKind): DraftRequirement { +/** + * Which licences a personal document is asked for. + * + * Empty means every licence (stored as a row with no licence type); otherwise + * one row per chosen type, all sharing the key. The applicant sees one slot + * either way — the portal collapses the rows by key — and only if they have + * declared operating as one of the types. + */ +export type PersonalScope = string[]; + +function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement { return { key: '', name: { en: '', am: '' }, applicationKind, - mode: 'ALWAYS', - allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'], + // A personal document is never demanded by one application, so "always + // required" would be a promise nothing here can keep. + mode: personal ? 'OPTIONAL' : 'ALWAYS', + allowedMimeTypes: [...DEFAULT_MIME_TYPES], maxSizeMb: 5, requiresValidityDates: false, allowMultiple: false, + isPersonal: personal, + maxFiles: personal ? 1 : null, sortOrder: 0, }; } -/** Adds/edits one document requirement slot for a licence type + application kind. */ +/** + * Adds/edits one document requirement slot. + * + * Two shapes of the same row: a slot on one licence type's application form, + * and — with `personal` — a document every applicant keeps in their own vault + * whatever they apply for. The vault has no application to condition on and no + * renewal of its own, so those fields are hidden rather than left to mean + * nothing. + */ export function DocumentRequirementEditorDrawer({ opened, onClose, @@ -49,19 +120,33 @@ export function DocumentRequirementEditorDrawer({ palette, conditionTargets, saving, + personal = false, + licenseTypes = [], + scope = [], }: { opened: boolean; onClose: () => void; /** Null = adding a new requirement. */ requirement: DocumentRequirement | null; defaultApplicationKind: ApplicationKind; - onSave: (draft: DraftRequirement) => void; + onSave: (draft: DraftRequirement, scope: PersonalScope) => void; palette: FormSchemaPalette | undefined; conditionTargets: ConditionTarget[]; saving: boolean; + /** Editing a personal document — one kept in the applicant's own vault. */ + personal?: boolean; + /** Licence types offered as scope; only read when `personal`. */ + licenseTypes?: LicenseType[]; + /** The licence types this document is already scoped to. */ + scope?: PersonalScope; }) { const { t } = useTranslation(); - const [draft, setDraft] = useState(emptyDraft(defaultApplicationKind)); + const localized = useLocalized(); + const [draft, setDraft] = useState( + emptyDraft(defaultApplicationKind, personal), + ); + const [scopeIds, setScopeIds] = useState(scope); + const [appliesToAll, setAppliesToAll] = useState(scope.length === 0); const [keyError, setKeyError] = useState(null); const isNew = !requirement; @@ -80,13 +165,19 @@ export function DocumentRequirementEditorDrawer({ maxSizeMb: requirement.maxSizeMb, requiresValidityDates: requirement.requiresValidityDates, allowMultiple: requirement.allowMultiple, + isPersonal: requirement.isPersonal ?? personal, + maxFiles: requirement.maxFiles ?? null, sortOrder: requirement.sortOrder, } - : emptyDraft(defaultApplicationKind), + : emptyDraft(defaultApplicationKind, personal), ); + setScopeIds(scope); + setAppliesToAll(scope.length === 0); setKeyError(null); } - }, [opened, requirement, defaultApplicationKind]); + // `scope` is a fresh array each render; the opened flag is what gates this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, requirement, defaultApplicationKind, personal]); function save() { if (!draft.key.trim()) { @@ -107,11 +198,22 @@ export function DocumentRequirementEditorDrawer({ setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition')); return; } - onSave({ - ...draft, - key: draft.key.trim(), - conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined, - }); + if (personal && !appliesToAll && scopeIds.length === 0) { + setKeyError(t('certReq.doc.scopeRequired', 'Choose at least one licence type')); + return; + } + onSave( + { + ...draft, + key: draft.key.trim(), + conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined, + isPersonal: personal, + // `allowMultiple` predates `maxFiles` and nothing reads it any more; + // kept in step so the two columns never contradict each other. + allowMultiple: draft.maxFiles !== 1, + }, + appliesToAll ? [] : scopeIds, + ); } return ( @@ -131,7 +233,13 @@ export function DocumentRequirementEditorDrawer({ error={keyError} disabled={!isNew} description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')} - onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + // The value is read out of the event first: a functional updater + // runs after React has released the event, so `currentTarget` is + // null by the time it would be read inside one. + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, key: value })); + }} /> setDraft((d) => ({ ...d, description: v }))} /> - v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} - allowDeselect={false} - /> + {!personal && ( + v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} + allowDeselect={false} + /> + )} + + {!personal && draft.mode === 'CONDITIONAL' && ( <> setDraft((d) => ({ ...d, allowedMimeTypes: v }))} @@ -199,16 +352,29 @@ export function DocumentRequirementEditorDrawer({ onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))} /> - setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))} - /> + {!personal && ( + { + const { checked } = e.currentTarget; + setDraft((d) => ({ ...d, requiresValidityDates: checked })); + }} + /> + )} - setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))} + + setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null })) + } /> (null); const requirements = useMemo( - () => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id), + // Personal documents can also name a licence type — they are asked for in + // the applicant's vault, not on this form, and are edited in Configuration. + () => + (data?.items ?? []).filter( + (r) => r.licenseTypeId === licenseType.id && !r.isPersonal, + ), [data, licenseType.id], ); const conditionTargets = collectConditionTargets(licenseType.formSchema.sections); @@ -127,7 +132,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT ) : ( {rows.map((req) => ( - +
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx index 53cd32174..370ba0572 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx @@ -113,7 +113,10 @@ export function FieldEditorDrawer({ ? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key') : t('certReq.field.keyLocked', 'Key cannot change once created') } - onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, key: value })); + }} /> setDraft((d) => ({ ...d, required: e.currentTarget.checked }))} + onChange={(e) => { + const { checked } = e.currentTarget; + setDraft((d) => ({ ...d, required: checked })); + }} /> - +
{localized(section.title) || section.key} @@ -237,7 +237,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) { {section.fields.map((field, fIndex) => ( - +
diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx new file mode 100644 index 000000000..4a2813ecc --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx @@ -0,0 +1,502 @@ +import { useMemo, useState } from 'react'; +import { + ActionIcon, + Alert, + Badge, + Button, + Group, + Modal, + Paper, + Select, + Stack, + Text, + TextInput, + Title, + Tooltip, +} from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { IconEdit, IconPlus, IconSearch, IconTrash, IconX } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { + AdvancedTable, + ModalFooter, + useServerTable, + type AdvancedColumn, +} from '@ema-platform/ui'; +import { + useCreateDocumentRequirementMutation, + useDeleteDocumentRequirementMutation, + useGetLicenseTypesQuery, + useGetPersonalDocumentsQuery, + useLocalized, + useUpdateDocumentRequirementMutation, + type DocumentRequirement, + type PersonalDocumentGroup as PersonalDocumentGroupDto, +} from '@ema-platform/api'; +import { useRequirementActions } from '../hooks/useRequirementActions'; +import { + DocumentRequirementEditorDrawer, + type PersonalScope, +} from './DocumentRequirementEditorDrawer'; + +type DraftRequirement = Omit; + +/** Filter value for "the documents every licence asks for". */ +const GLOBAL_ONLY = 'GLOBAL'; + +const SEARCH_DEBOUNCE_MS = 300; + +/** + * Badge colours for licence types. + * + * Red is left out: it reads as a problem, and a licence type is not one. + * Everything else the theme offers is in, because the point of the colour is + * telling two licence types apart at a glance. + */ +const SCOPE_COLORS = [ + 'blue', + 'grape', + 'teal', + 'orange', + 'violet', + 'cyan', + 'pink', + 'lime', + 'indigo', + 'green', + 'yellow', + 'gray', +]; + +/** Doubles the palette: the same hue, a visibly different badge. */ +const SCOPE_VARIANTS = ['light', 'outline'] as const; + +/** + * A colour per licence type, assigned by position in the catalogue. + * + * Hashing the id looked tidier and was wrong: eight buckets over sixteen + * licence types collide by the pigeonhole principle, so Vessel Registration + * and Freight Forwarder came out the same colour and the badge stopped + * carrying information. Walking the sorted catalogue instead gives every type + * a distinct colour until the palette runs out, and only then repeats a hue in + * the other variant — 24 distinct badges before any two can look alike. + * + * Sorted by `sortOrder` so the assignment is the same for every officer and + * survives a refresh; a type added later takes the next free style rather than + * reshuffling the ones already learned. + */ +function buildScopeStyles( + types: { id: string; sortOrder: number }[], +): Map { + const styles = new Map< + string, + { color: string; variant: (typeof SCOPE_VARIANTS)[number] } + >(); + types + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .forEach((type, index) => { + styles.set(type.id, { + color: SCOPE_COLORS[index % SCOPE_COLORS.length], + variant: + SCOPE_VARIANTS[ + Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length + ], + }); + }); + return styles; +} + +/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */ +const MIME_LABELS: Record = { + 'application/pdf': 'PDF', + 'application/msword': 'DOC', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX', + 'application/vnd.ms-excel': 'XLS', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX', +}; + +function shortMime(mime: string): string { + return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime; +} + +/** + * A document as the table renders it: what the server sent, plus the row id + * `AdvancedTable` keys on and the scope read off its rows. + * + * The grouping itself belongs to the server — a page of rows would split a + * document configured for three licence types across two pages and misreport + * the scope of both halves. + */ +interface PersonalDocumentGroup extends PersonalDocumentGroupDto { + /** The key doubles as the row id; one group is one document. */ + id: string; + /** Empty when the document applies to every licence. */ + scope: PersonalScope; +} + +/** + * Documents an applicant keeps in their own vault. + * + * Same `document_requirements` table as a licence type's upload slots, flagged + * `isPersonal`: these are not asked for on an application form but held once, + * under My Documents in the portal. A document can apply to every licence or + * only to the modes of operation an applicant has declared — a sea service + * book is worth asking a seafarer for and pointless for a freight forwarder. + */ +export function PersonalDocumentsCard() { + const { t, i18n } = useTranslation(); + const localized = useLocalized(); + const run = useRequirementActions(); + + const { data: licenseTypes } = useGetLicenseTypesQuery(); + const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation(); + const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation(); + const [deleteRequirement] = useDeleteDocumentRequirementMutation(); + + const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + /** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */ + const [licenseTypeFilter, setLicenseTypeFilter] = useState(null); + + const { pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({ + pageSize: 10, + }); + const [searchInput, setSearchInput] = useState(''); + // Typing must not fire a request per keystroke; same 300ms as the queue. + const [search] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS); + + // Every facet goes to the server: it filters and searches in SQL, groups the + // rows into documents, then pages the documents. + const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({ + search: search.trim() || undefined, + licenseTypeId: + licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY + ? licenseTypeFilter + : undefined, + globalOnly: licenseTypeFilter === GLOBAL_ONLY, + take: pageSize, + skip: pageIndex * pageSize, + locale: i18n.language === 'am' ? 'am' : 'en', + }); + + const groups = useMemo( + () => + (data?.items ?? []).map((group) => ({ + ...group, + id: group.key, + // A single row with no licence type means "every licence"; the two + // never coexist, because the editor writes one shape or the other. + scope: group.rows + .map((r) => r.licenseTypeId) + .filter((id): id is string => id !== null), + })), + [data], + ); + + /** Filters are the server's business now; an empty page is its answer. */ + const isFiltered = search.trim() !== '' || licenseTypeFilter !== null; + + function clearFilters() { + setSearchInput(''); + setLicenseTypeFilter(null); + setPageIndex(0); + } + + const scopeStyles = useMemo( + () => buildScopeStyles(licenseTypes?.items ?? []), + [licenseTypes], + ); + + const typeName = (id: string) => { + const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id); + return found ? localized(found.name) || found.key : id; + }; + + const columns = useMemo[]>( + () => [ + { + header: t('certReq.personal.columns.document', 'Document'), + label: t('certReq.personal.columns.document', 'Document'), + cell: ({ row }) => ( +
+ + {localized(row.original.rows[0].name) || row.original.key} + + + {row.original.key} + +
+ ), + }, + { + header: t('certReq.doc.scope', 'Applies to'), + label: t('certReq.doc.scope', 'Applies to'), + cell: ({ row }) => + row.original.scope.length === 0 ? ( + // Filled, where a licence type is outlined: "every licence" is a + // different kind of answer, not one more item in the same list. + + {t('certReq.doc.scopeAll', 'All licences')} + + ) : ( + + {row.original.scope.map((id) => { + // A type the catalogue no longer lists still needs a badge. + const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' }; + return ( + + {typeName(id)} + + ); + })} + + ), + }, + { + header: t('certReq.doc.maxFiles', 'Files accepted'), + label: t('certReq.doc.maxFiles', 'Files accepted'), + align: 'center', + cell: ({ row }) => + row.original.rows[0].maxFiles === null + ? t('certReq.doc.maxFilesUnlimited', 'No limit') + : row.original.rows[0].maxFiles, + }, + { + header: t('certReq.doc.allowedTypes', 'Allowed file types'), + label: t('certReq.doc.allowedTypes', 'Allowed file types'), + cell: ({ row }) => { + const types = row.original.rows[0].allowedMimeTypes ?? []; + return ( + // Twenty-odd mime types would own the row; the full list is one + // hover away instead. + + + {types.slice(0, 3).map(shortMime).join(', ')} + {types.length > 3 + ? t('certReq.personal.moreTypes', ' +{{count}} more', { + count: types.length - 3, + }) + : ''} + + + ); + }, + }, + { + header: t('certReq.doc.maxSize', 'Max file size (MB)'), + label: t('certReq.doc.maxSize', 'Max file size (MB)'), + align: 'center', + cell: ({ row }) => `${row.original.rows[0].maxSizeMb} MB`, + }, + { + header: '', + label: t('certReq.personal.columns.actions', 'Actions'), + align: 'right', + cell: ({ row }) => ( + + setEditing({ group: row.original })} + > + + + setDeleteTarget(row.original)} + > + + + + ), + }, + ], + // `typeName` and `scopeStyles` both close over the licence-type list. + // eslint-disable-next-line react-hooks/exhaustive-deps + [t, localized, licenseTypes, scopeStyles], + ); + + /** + * Saves the group as the set of rows it now means. + * + * The scope is edited as a whole, so the diff is the honest way to apply it: + * rows for licence types that were added get created, rows for types that + * were dropped get deleted, and everything still in scope is updated. A + * document moved to "all licences" collapses to a single row with none. + */ + async function handleSave(draft: DraftRequirement, scope: PersonalScope) { + const existing = editing?.group?.rows ?? []; + // `null` is a licence type here too — the one meaning "every licence". + const wanted: (string | null)[] = scope.length ? scope : [null]; + + const ok = await run(async () => { + const stale = existing.filter((row) => !wanted.includes(row.licenseTypeId)); + const kept = existing.filter((row) => wanted.includes(row.licenseTypeId)); + const added = wanted.filter( + (id) => !existing.some((row) => row.licenseTypeId === id), + ); + + await Promise.all([ + ...kept.map((row) => updateRequirement({ id: row.id, ...draft }).unwrap()), + ...added.map((licenseTypeId) => + createRequirement({ ...draft, licenseTypeId }).unwrap(), + ), + ...stale.map((row) => deleteRequirement(row.id).unwrap()), + ]); + }, editing?.group + ? t('certReq.doc.updated', 'Document requirement updated') + : t('certReq.doc.created', 'Document requirement added')); + + if (ok) setEditing(null); + } + + async function confirmDelete() { + if (!deleteTarget) return; + const ok = await run( + () => Promise.all(deleteTarget.rows.map((row) => deleteRequirement(row.id).unwrap())), + t('certReq.doc.deleted', 'Document requirement removed'), + ); + if (ok) setDeleteTarget(null); + } + + return ( + + +
+ {t('certReq.personal.title', 'Personal documents')} + + {t( + 'certReq.personal.subtitle', + 'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.', + )} + +
+ +
+ + {/* Facets, in the shape the licence-review queue uses. No date range: + a configuration row has no submission date to filter on. */} + + + } + value={searchInput} + onChange={(e) => { + const { value } = e.currentTarget; + setSearchInput(value); + setPageIndex(0); + }} + w={240} + /> + setFacet({ kind: (v as ApplicationKind) ?? undefined })} + clearable + w={180} + /> ( "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">( + "MORNING", + ); const [resultOpen, setResultOpen] = useState(false); const [findings, setFindings] = useState(""); const [findingsUploadBusy, setFindingsUploadBusy] = useState(false); @@ -312,10 +321,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 } = @@ -559,12 +572,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; @@ -848,6 +878,11 @@ export function LicenseReviewPage() { {t(`queue.statusValues.${status}`, STATUS_LABELS[status])} + {data.issuedLicenseStatus === "SUPERSEDED" && ( + + {t("review.certificateSuperseded", "Certificate superseded")} + + )} {app.adjustmentRound > 0 && ( {t("review.round", { @@ -1210,18 +1245,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 ? ( @@ -1254,21 +1277,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, + )} + +
))}
@@ -1277,6 +1327,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 && ( +