From f255621672fe8383894718a9f0ed2470b013396b Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 08:54:10 +0000 Subject: [PATCH 01/21] feat: add support for global personal document requirements in applicant vaults --- .../DocumentRequirementEditorDrawer.tsx | 112 ++++-- .../components/PersonalDocumentsCard.tsx | 186 ++++++++++ .../pages/CertificateRequirementsPage.tsx | 6 + apps/backoffice/src/app/i18n/locales/am.ts | 14 + apps/backoffice/src/app/i18n/locales/en.ts | 14 + .../components/PersonalDocumentSlots.tsx | 333 ++++++++++++++++++ .../documents/pages/DocumentVaultPage.tsx | 129 +------ apps/portal/src/app/i18n/locales/am.ts | 24 +- apps/portal/src/app/i18n/locales/en.ts | 26 +- libs/api/src/index.ts | 1 + .../lib/features/licensing/licensing-api.ts | 4 +- .../lib/features/licensing/licensing.types.ts | 9 +- .../lib/features/personal-document/index.ts | 2 + .../personal-document-api.ts | 69 ++++ .../personal-document.types.ts | 22 ++ 15 files changed, 780 insertions(+), 171 deletions(-) create mode 100644 apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx create mode 100644 apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx create mode 100644 libs/api/src/lib/features/personal-document/index.ts create mode 100644 libs/api/src/lib/features/personal-document/personal-document-api.ts create mode 100644 libs/api/src/lib/features/personal-document/personal-document.types.ts 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..24cf3c400 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -25,21 +25,32 @@ const MIME_OPTIONS = [ type DraftRequirement = Omit; -function emptyDraft(applicationKind: ApplicationKind): DraftRequirement { +function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement { return { key: '', name: { en: '', am: '' }, applicationKind, - mode: 'ALWAYS', + // 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: ['application/pdf', 'image/jpeg', 'image/png'], maxSizeMb: 5, requiresValidityDates: false, allowMultiple: false, + 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,6 +60,7 @@ export function DocumentRequirementEditorDrawer({ palette, conditionTargets, saving, + personal = false, }: { opened: boolean; onClose: () => void; @@ -59,9 +71,13 @@ export function DocumentRequirementEditorDrawer({ palette: FormSchemaPalette | undefined; conditionTargets: ConditionTarget[]; saving: boolean; + /** Editing a personal document — one that applies to every licence. */ + personal?: boolean; }) { const { t } = useTranslation(); - const [draft, setDraft] = useState(emptyDraft(defaultApplicationKind)); + const [draft, setDraft] = useState( + emptyDraft(defaultApplicationKind, personal), + ); const [keyError, setKeyError] = useState(null); const isNew = !requirement; @@ -80,13 +96,14 @@ export function DocumentRequirementEditorDrawer({ maxSizeMb: requirement.maxSizeMb, requiresValidityDates: requirement.requiresValidityDates, allowMultiple: requirement.allowMultiple, + maxFiles: requirement.maxFiles ?? null, sortOrder: requirement.sortOrder, } - : emptyDraft(defaultApplicationKind), + : emptyDraft(defaultApplicationKind, personal), ); setKeyError(null); } - }, [opened, requirement, defaultApplicationKind]); + }, [opened, requirement, defaultApplicationKind, personal]); function save() { if (!draft.key.trim()) { @@ -111,6 +128,9 @@ export function DocumentRequirementEditorDrawer({ ...draft, key: draft.key.trim(), conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined, + // `allowMultiple` predates `maxFiles` and nothing reads it any more; kept + // in step so the two columns never contradict each other. + allowMultiple: draft.maxFiles !== 1, }); } @@ -147,32 +167,36 @@ export function DocumentRequirementEditorDrawer({ onChange={(v) => setDraft((d) => ({ ...d, description: v }))} /> - v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))} + allowDeselect={false} + disabled={!isNew} + description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined} + /> + )} - v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} + allowDeselect={false} + /> + )} - {draft.mode === 'CONDITIONAL' && ( + {!personal && draft.mode === 'CONDITIONAL' && ( <> setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))} /> - setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))} - /> + {!personal && ( + setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))} + /> + )} - setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))} + + setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null })) + } /> (null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const rows = useMemo( + () => + (data?.items ?? []) + .filter((r) => r.licenseTypeId === null) + .sort((a, b) => a.sortOrder - b.sortOrder), + [data], + ); + + async function handleSave(draft: Omit) { + const ok = await run( + () => + editing?.requirement + ? updateRequirement({ id: editing.requirement.id, ...draft }).unwrap() + : // No licenceTypeId at all: that absence is what makes it personal. + createRequirement(draft).unwrap(), + editing?.requirement + ? 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( + () => deleteRequirement(deleteTarget.id).unwrap(), + t('certReq.doc.deleted', 'Document requirement removed'), + ); + if (ok) setDeleteTarget(null); + } + + return ( + + + {t('certReq.personal.title', 'Documents required for every licence')} + + + + {t( + 'certReq.personal.subtitle', + 'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application.', + )} + + + {rows.length === 0 ? ( + + {t('certReq.personal.empty', 'No personal documents configured yet.')} + + ) : ( + + {rows.map((req) => ( + + +
+ + + {localized(req.name) || req.key} + + + {req.maxFiles === null + ? t('certReq.doc.maxFilesUnlimited', 'No limit') + : t('certReq.personal.fileCount', '{{count}} file', { count: req.maxFiles })} + + + + key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')} + +
+ + setEditing({ requirement: req })} + > + + + setDeleteTarget(req)}> + + + +
+
+ ))} +
+ )} + + setEditing(null)} + requirement={editing?.requirement ?? null} + defaultApplicationKind="NEW" + onSave={handleSave} + palette={undefined} + conditionTargets={[]} + saving={creating || updating} + personal + /> + + setDeleteTarget(null)} + title={t('certReq.doc.delete', 'Delete document requirement')} + size="sm" + > + + + {t( + 'certReq.personal.deleteWarning', + 'The slot disappears from every applicant’s My Documents. Files already uploaded are kept, but nobody can reach them.', + )} + + + {t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', { + name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '', + })} + + + + + + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx index 86352bb97..7cb3cc5d7 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -6,6 +6,7 @@ import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui'; import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api'; import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab'; import { FormSchemaTab } from '../components/FormSchemaTab'; +import { PersonalDocumentsCard } from '../components/PersonalDocumentsCard'; /** * Where an administrator configures what an applicant must fill in and @@ -58,6 +59,11 @@ export function CertificateRequirementsPage() { ) : ( + {/* Above the licence-type picker on purpose: these documents + belong to no single type, so filing them under whichever one + happens to be selected would misread. */} + + }> {t("configuration.ranksTab", "Ranks & Departments")} + }> + {t("configuration.personalDocumentsTab", "Personal Documents")} + @@ -427,6 +434,10 @@ export function ConfigurationPage() { + + + + ); diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 4d2bf9ae7..cfdc6a75b 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -818,6 +818,7 @@ export const am: Translations = { configuration: { title: "ውቅረት", + personalDocumentsTab: "የግል ሰነዶች", departments: "ክፍሎች", professions: "ሙያዎች", departmentsList: "ክፍሎች", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 282398f35..c5ff4d50f 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -822,6 +822,7 @@ export const en = { configuration: { title: 'Configuration', + personalDocumentsTab: 'Personal Documents', departments: 'Departments', professions: 'Professions', departmentsList: 'Departments', From 19ef4215e042d8e4c22fe1375eb4ab81c9f1b5bf Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 09:56:04 +0000 Subject: [PATCH 03/21] feat: add scope filtering for personal documents in configuration and API --- .../DocumentRequirementEditorDrawer.tsx | 98 +++++++- .../components/DocumentRequirementsTab.tsx | 7 +- .../components/PersonalDocumentsCard.tsx | 214 ++++++++++++------ apps/backoffice/src/app/i18n/locales/am.ts | 10 +- apps/backoffice/src/app/i18n/locales/en.ts | 11 +- .../lib/features/licensing/licensing-api.ts | 15 +- .../lib/features/licensing/licensing.types.ts | 6 + 7 files changed, 278 insertions(+), 83 deletions(-) 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 24cf3c400..d0fb51d9f 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,7 +14,13 @@ 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'; @@ -25,6 +32,16 @@ const MIME_OPTIONS = [ type DraftRequirement = Omit; +/** + * 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: '', @@ -37,6 +54,7 @@ function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftR maxSizeMb: 5, requiresValidityDates: false, allowMultiple: false, + isPersonal: personal, maxFiles: personal ? 1 : null, sortOrder: 0, }; @@ -61,23 +79,32 @@ export function DocumentRequirementEditorDrawer({ 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 that applies to every licence. */ + /** 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 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; @@ -96,13 +123,18 @@ 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, personal), ); + setScopeIds(scope); + setAppliesToAll(scope.length === 0); setKeyError(null); } + // `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() { @@ -124,14 +156,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, - // `allowMultiple` predates `maxFiles` and nothing reads it any more; kept - // in step so the two columns never contradict each other. - allowMultiple: draft.maxFiles !== 1, - }); + 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 ( @@ -167,6 +207,42 @@ export function DocumentRequirementEditorDrawer({ onChange={(v) => setDraft((d) => ({ ...d, description: v }))} /> + {personal && ( + + + {t('certReq.doc.scope', 'Applies to')} + + setAppliesToAll(v === 'all')} + data={[ + { value: 'all', label: t('certReq.doc.scopeAll', 'All licences') }, + { + value: 'selected', + label: t('certReq.doc.scopeSelected', 'Selected licence types'), + }, + ]} + /> + {!appliesToAll && ( + a.sortOrder - b.sortOrder) + .map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key }))} + value={scopeIds} + onChange={setScopeIds} + searchable + placeholder={t('certReq.doc.scopePlaceholder', 'Choose licence types')} + description={t( + 'certReq.doc.scopeHelp', + 'Only applicants who declared one of these as a mode of operation are asked for it.', + )} + /> + )} + + )} + {!personal && ( + {isFiltered && ( + + )} + + + + diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 6761af0d8..dcfd129cc 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1374,6 +1374,7 @@ export const am: Translations = { "አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።", add: "የግል ሰነድ ጨምር", empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።", + search: "ፍለጋ", searchPlaceholder: "በስም ወይም በቁልፍ ይፈልጉ", moreTypes: " +{{count}} ተጨማሪ", columns: { @@ -1383,7 +1384,7 @@ export const am: Translations = { filterAny: "ማንኛውም የፈቃድ አይነት", filterGlobal: "ለሁሉም ፈቃዶች የሚሆኑ ብቻ", noMatch: "በእነዚህ ማጣሪያዎች የሚመጣጠን የግል ሰነድ የለም።", - clearFilters: "ማጣሪያዎችን አጽዳ", + clearFilters: "አጽዳ", fileCount_one: "{{count}} ፋይል", fileCount_other: "{{count}} ፋይሎች", deleteWarning: diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 829496b0b..03e7d053e 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1380,6 +1380,7 @@ export const en = { '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.', add: 'Add personal document', empty: 'No personal documents configured yet.', + search: 'Search', searchPlaceholder: 'Search by name or key', moreTypes: ' +{{count}} more', columns: { @@ -1389,7 +1390,7 @@ export const en = { filterAny: 'Any licence type', filterGlobal: 'All-licence documents only', noMatch: 'No personal document matches those filters.', - clearFilters: 'Clear filters', + clearFilters: 'Clear', fileCount_one: '{{count}} file', fileCount_other: '{{count}} files', deleteWarning: From c4b903bc497c8b805635fa8d04112f2630681c4d Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 11:33:07 +0000 Subject: [PATCH 12/21] feat: implement deterministic color-coding for licence type badges and update default scope badge style --- .../components/PersonalDocumentsCard.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx index dfaa16175..b15a46869 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx @@ -46,6 +46,37 @@ const GLOBAL_ONLY = 'GLOBAL'; const SEARCH_DEBOUNCE_MS = 300; +/** + * Badge colours for licence types. + * + * Red and yellow are left out on purpose: they read as a problem, and a + * licence type is not one. Green is out too — it means verified elsewhere in + * the backoffice. + */ +const SCOPE_COLORS = [ + 'grape', + 'violet', + 'indigo', + 'cyan', + 'teal', + 'pink', + 'orange', + 'blue', +]; + +/** + * The same licence type keeps the same colour on every row and every visit — + * derived from its id rather than its position, so a colour is something the + * eye can learn instead of a lottery per render. + */ +function scopeColor(licenseTypeId: string): string { + let hash = 0; + for (let i = 0; i < licenseTypeId.length; i += 1) { + hash = (hash * 31 + licenseTypeId.charCodeAt(i)) % 997; + } + return SCOPE_COLORS[hash % SCOPE_COLORS.length]; +} + /** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */ const MIME_LABELS: Record = { 'application/pdf': 'PDF', @@ -168,13 +199,15 @@ export function PersonalDocumentsCard() { 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) => ( - + {typeName(id)} ))} From 3b0e5b1eb4f87ce052a7c11d45a08acb69964f1e Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 11:39:47 +0000 Subject: [PATCH 13/21] refactor: replace hash-based license badge coloring with a deterministic, collision-resistant palette based on sort order --- .../components/PersonalDocumentsCard.tsx | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx index b15a46869..4a2813ecc 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx @@ -49,32 +49,62 @@ const SEARCH_DEBOUNCE_MS = 300; /** * Badge colours for licence types. * - * Red and yellow are left out on purpose: they read as a problem, and a - * licence type is not one. Green is out too — it means verified elsewhere in - * the backoffice. + * 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 = [ - 'grape', - 'violet', - 'indigo', - 'cyan', - 'teal', - 'pink', - 'orange', '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; + /** - * The same licence type keeps the same colour on every row and every visit — - * derived from its id rather than its position, so a colour is something the - * eye can learn instead of a lottery per render. + * 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 scopeColor(licenseTypeId: string): string { - let hash = 0; - for (let i = 0; i < licenseTypeId.length; i += 1) { - hash = (hash * 31 + licenseTypeId.charCodeAt(i)) % 997; - } - return SCOPE_COLORS[hash % SCOPE_COLORS.length]; +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. */ @@ -173,6 +203,11 @@ export function PersonalDocumentsCard() { 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; @@ -206,11 +241,15 @@ export function PersonalDocumentsCard() { ) : ( - {row.original.scope.map((id) => ( - - {typeName(id)} - - ))} + {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)} + + ); + })} ), }, @@ -274,9 +313,9 @@ export function PersonalDocumentsCard() { ), }, ], - // `typeName` closes over the licence-type list, which `localized` also reads. + // `typeName` and `scopeStyles` both close over the licence-type list. // eslint-disable-next-line react-hooks/exhaustive-deps - [t, localized, licenseTypes], + [t, localized, licenseTypes, scopeStyles], ); /** From 2e9083211fa519ac9c1bcc8a1cb6679774a6dd8a Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 29 Aug 2026 06:34:33 +0000 Subject: [PATCH 14/21] feat: add configuration UI for license behavior and multi-stage examination fees --- .../components/BehaviorTab.tsx | 422 ++++++++++++++++++ .../pages/CertificateRequirementsPage.tsx | 16 +- .../pages/PaymentConfigPage/index.tsx | 86 ++++ apps/backoffice/src/app/i18n/locales/am.ts | 73 +++ apps/backoffice/src/app/i18n/locales/en.ts | 70 +++ .../lib/features/licensing/licensing-api.ts | 52 +++ .../features/licensing/licensing.helpers.ts | 9 + .../lib/features/licensing/licensing.types.ts | 37 ++ 8 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx new file mode 100644 index 000000000..28f348ab1 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -0,0 +1,422 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Button, + Group, + MultiSelect, + NumberInput, + Paper, + Select, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth'; +import { + useUpdateLicenseBehaviorMutation, + type CertificateCategory, + type CompletionEffect, + type LicenseType, + type ServiceKind, + type WorkflowProfile, +} from '@ema-platform/api'; +import { useRequirementActions } from '../hooks/useRequirementActions'; + +/** The shape the form edits — every field the behaviour endpoint accepts. */ +interface Draft { + workflowProfile: WorkflowProfile; + serviceKind: ServiceKind; + completionEffect: CompletionEffect | null; + certificateCategory: CertificateCategory | null; + requiresExamination: boolean; + requiresSeafarerRegistration: boolean; + requiresValidMedical: boolean; + minSeaTimeDays: number | null; + renewalWindowDays: number; + expiryReminderDays: number[]; + requiresOperatorMode: boolean; + allowMultipleOpenDrafts: boolean; + requiresIssuanceScheduling: boolean; + uniqueFormKeyPath: string | null; + slaHours: number | null; +} + +/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */ +const REMINDER_OFFSETS = ['90', '60', '30', '14', '7']; + +function toDraft(licenseType: LicenseType): Draft { + return { + workflowProfile: licenseType.workflowProfile ?? 'STANDARD', + serviceKind: licenseType.serviceKind ?? 'LICENSE', + completionEffect: licenseType.completionEffect ?? null, + certificateCategory: licenseType.certificateCategory ?? null, + requiresExamination: licenseType.requiresExamination ?? false, + requiresSeafarerRegistration: + licenseType.requiresSeafarerRegistration ?? false, + requiresValidMedical: licenseType.requiresValidMedical ?? false, + minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + renewalWindowDays: licenseType.renewalWindowDays ?? 60, + expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], + requiresOperatorMode: licenseType.requiresOperatorMode ?? true, + allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false, + requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false, + uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null, + slaHours: licenseType.slaHours ?? null, + }; +} + +/** + * How one licence type behaves: the course it runs, who may apply, when it + * renews and what rules the applicant meets. + * + * These were seed-only until now — changing an SLA target or a renewal window + * meant editing a file and redeploying. They are read live off the licence + * type rather than snapshotted onto applications, so a change here applies to + * files already in the queue as well as new ones. For the three settings that + * decide an application's course the server refuses the change outright while + * anything is still awaiting a decision, rather than stranding it. + */ +export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const run = useRequirementActions(); + const { can } = usePermissions(); + const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]); + + const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation(); + const [draft, setDraft] = useState(() => toDraft(licenseType)); + const [dirty, setDirty] = useState(false); + + // Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates + // the licence-type list, and the refetch that follows must not overwrite an + // edit the administrator is still working on. + useEffect(() => { + setDraft(toDraft(licenseType)); + setDirty(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [licenseType.id]); + + function set(key: K, value: Draft[K]) { + setDraft((current) => ({ ...current, [key]: value })); + setDirty(true); + } + + async function onSave() { + const ok = await run( + () => save({ id: licenseType.id, ...draft }).unwrap(), + t('certReq.behavior.saved', 'Configuration saved.'), + ); + if (ok) setDirty(false); + } + + return ( + +
+ } + > + + {t( + 'certReq.behavior.workflowWarning', + 'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.', + )} + + + + set('completionEffect', (v as CompletionEffect) ?? null)} + placeholder={t('certReq.behavior.noEffect', 'No side effect')} + clearable + disabled={!canEdit} + /> + + set('requiresExamination', e.currentTarget.checked)} + label={t('certReq.behavior.requiresExamination', 'Requires an examination')} + description={t( + 'certReq.behavior.requiresExaminationHint', + 'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.', + )} + disabled={!canEdit} + /> + + ({ + value: v, + label: v, + }))} + value={draft.certificateCategory} + onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)} + placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')} + clearable + disabled={!canEdit} + /> +
+ +
+ 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('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, +}: { + label: string; + switchLabel: string; + description?: string; + value: number | null; + onChange: (value: number | null) => void; + disabled: boolean; + min: number; +}) { + return ( + + onChange(e.currentTarget.checked ? min || 1 : null)} + label={switchLabel} + description={description} + disabled={disabled} + /> + {value !== null && ( + onChange(typeof v === 'number' ? v : value)} + min={min} + allowNegative={false} + disabled={disabled} + /> + )} + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx index 86352bb97..f6ccf9c36 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -1,9 +1,16 @@ import { useEffect, useState } from 'react'; import { Container, Select, Stack, Tabs } from '@mantine/core'; -import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react'; +import { + IconAdjustments, + IconAlertCircle, + IconFileText, + IconFiles, + IconSettings, +} from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui'; import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api'; +import { BehaviorTab } from '../components/BehaviorTab'; import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab'; import { FormSchemaTab } from '../components/FormSchemaTab'; @@ -78,6 +85,9 @@ export function CertificateRequirementsPage() { }> {t('certReq.tabDocuments', 'Document requirements')} + }> + {t('certReq.tabBehavior', 'Behaviour')} + @@ -87,6 +97,10 @@ export function CertificateRequirementsPage() { + + + + )} diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx index adaca4a96..03ce1673e 100644 --- a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx @@ -179,9 +179,25 @@ function FeeEditModal({ // are real configuration states, not blank fields. const [chargeable, setChargeable] = useState(true); const [sameAsNew, setSameAsNew] = useState(true); + // The examined-certificate stages. Held the same way — null means "this stage + // is not charged", which is different from a blank box. + const [eligibilityFee, setEligibilityFee] = useState(''); + const [examinationFee, setExaminationFee] = useState(''); + const [certificateFee, setCertificateFee] = useState(''); + + const examined = licenseType?.requiresExamination ?? false; useEffect(() => { if (!licenseType) return; + setEligibilityFee( + licenseType.feeEligibility == null ? '' : Number(licenseType.feeEligibility), + ); + setExaminationFee( + licenseType.feeExamination == null ? '' : Number(licenseType.feeExamination), + ); + setCertificateFee( + licenseType.feeCertificate == null ? '' : Number(licenseType.feeCertificate), + ); setChargeable(licenseType.feeNewApplication !== null); setNewFee( licenseType.feeNewApplication === null @@ -221,6 +237,14 @@ function FeeEditModal({ feeNewApplication: chargeable ? Number(newFee) : null, feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null, feeCurrency: currency.trim() || 'ETB', + // Only sent for examined types; otherwise the columns stay untouched. + ...(examined + ? { + feeEligibility: eligibilityFee === '' ? null : Number(eligibilityFee), + feeExamination: examinationFee === '' ? null : Number(examinationFee), + feeCertificate: certificateFee === '' ? null : Number(certificateFee), + } + : {}), }).unwrap(); notify.success( t('paymentConfig.modal.updated', { @@ -325,6 +349,68 @@ function FeeEditModal({ )} + {examined && ( + <> + } + > + + {t( + 'paymentConfig.modal.examinedNotice', + 'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.', + )} + + + + setEligibilityFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setExaminationFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setCertificateFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + )} + - - + {/* Read-only here. Validity is one policy decision with the renewal + window and the expiry reminders, so it is edited in one place — + Certificate Requirements → Behaviour — rather than from two screens + behind two different permissions. Still shown, because a designer + laying out a certificate that prints an expiry needs to see the term + it promises. */} + {typeId && ( + + + {t('designer.validityYears', 'Valid for (years)')} + + + + {Number((validityMonths / 12).toFixed(2))} + + + {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} + + + + )}
diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index f9401f3e4..8f5597f49 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -29,7 +29,6 @@ import { useGetRanksQuery, useGetTemplateVariablesQuery, usePublishLicenseTemplateMutation, - useUpdateLicenseValidityMutation, useUpdateLicenseTemplateMutation, } from '@ema-platform/api'; import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; @@ -88,7 +87,6 @@ export function CertificateDesignerPage() { const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation(); const [archiveTemplate] = useArchiveLicenseTemplateMutation(); const [deleteTemplate] = useDeleteLicenseTemplateMutation(); - const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation(); const draft = useTemplateDraft(templates); const run = useDesignerActions(); @@ -96,7 +94,6 @@ export function CertificateDesignerPage() { const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); - const [validityMonths, setValidityMonths] = useState(12); const [mode, setMode] = useState<'canvas' | 'source'>('canvas'); const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); @@ -127,10 +124,6 @@ export function CertificateDesignerPage() { if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); }, [licenseTypes, typeId]); - useEffect(() => { - if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12); - }, [selectedType]); - // Switching licence type leaves a stale rank selected from the previous // type's ladder — reset to the type's default design. useEffect(() => { @@ -169,17 +162,8 @@ export function CertificateDesignerPage() { setRankId(value); draft.setSelectedId(null); }} - validityMonths={validityMonths} - onValidityChange={setValidityMonths} - currentValidityMonths={selectedType?.validityMonths} + validityMonths={selectedType?.validityMonths ?? 12} canEdit={canEdit} - savingValidity={savingValidity} - onSaveValidity={() => - run( - () => updateValidity({ id: typeId as string, validityMonths }).unwrap(), - t('designer.validitySaved', 'Validity updated'), - ) - } onNewVersion={startNewVersion} /> diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx index 28f348ab1..6acfd90b4 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -36,6 +36,8 @@ interface Draft { requiresSeafarerRegistration: boolean; requiresValidMedical: boolean; minSeaTimeDays: number | null; + capitalThreshold: number | null; + validityMonths: number; renewalWindowDays: number; expiryReminderDays: number[]; requiresOperatorMode: boolean; @@ -59,6 +61,11 @@ function toDraft(licenseType: LicenseType): Draft { licenseType.requiresSeafarerRegistration ?? false, requiresValidMedical: licenseType.requiresValidMedical ?? false, minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + capitalThreshold: + licenseType.capitalThreshold == null + ? null + : Number(licenseType.capitalThreshold), + validityMonths: licenseType.validityMonths ?? 12, renewalWindowDays: licenseType.renewalWindowDays ?? 60, expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], requiresOperatorMode: licenseType.requiresOperatorMode ?? true, @@ -194,10 +201,25 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { {t( 'certReq.behavior.eligibilityHint', - 'Checked when an applicant submits. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', + '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)} @@ -243,7 +265,27 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { /> -
+
+ {/* Stored in months, edited in years: a licence term is a number of + years to everyone who works with one. Half-years stay expressible. */} + + set('validityMonths', Math.round(Number(v || 0) * 12) || draft.validityMonths) + } + min={0.5} + max={20} + step={0.5} + decimalScale={1} + allowNegative={false} + disabled={!canEdit} + /> + 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 ? min || 1 : null)} + onChange={(e) => + onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null) + } label={switchLabel} description={description} disabled={disabled} @@ -414,6 +463,8 @@ function NullableNumber({ onChange={(v) => 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/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 3c8eb5a9f..d83b5d451 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1223,12 +1223,10 @@ export const am: Translations = { designer: { title: "የምስክር ወረቀት ንድፍ", - subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።", + subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።", licenceType: "የፈቃድ ዓይነት", validityYears: "የሚቆይበት (ዓመታት)", - validityHint: "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል", - saveValidity: "የሚቆይበትን ጊዜ አስቀምጥ", - validitySaved: "የሚቆይበት ጊዜ ተዘምኗል", + validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል", newVersion: "አዲስ ስሪት", versions: "ስሪቶች", name: "የስሪት ስም", @@ -1296,7 +1294,14 @@ export const am: Translations = { registrationKind: "ምዝገባ", eligibility: "የብቁነት መስፈርቶች", eligibilityHint: - "አመልካቹ ሲያስገባ ይመረመራሉ። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።", + "አመልካቹ ሲያስገባ ይመረመራሉ፤ የካፒታል መስፈርቱ ደግሞ ኃላፊው ሲያጸድቅ እንደገና ይመረመራል። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።", + capitalThreshold: "አነስተኛ የተከፈለ ካፒታል", + capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ", + capitalHint: + "ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።", + validityYears: "የሚቆይበት (ዓመታት)", + validityHint: + "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።", requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል", requiresSeafarerHint: "ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።", @@ -1307,7 +1312,7 @@ export const am: Translations = { certificateCategoryHint: "ይህ የሚሰጠው የሰነድ ዓይነት። ማረጋገጫዎች በመርከበኛው ፖርታል ላይ ለብቻቸው ይመደባሉ።", notACertificate: "የምስክር ወረቀት አይደለም", - renewal: "እድሳት", + renewal: "የሚቆይበት ጊዜና እድሳት", renewalWindow: "እድሳት የሚከፈትበት (ጊዜው ከማብቃቱ በፊት ያሉ ቀናት)", reminders: "የማብቂያ አስታዋሾች (ቀደም ብለው ያሉ ቀናት)", remindersHint: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 40e501bdc..8279081ec 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1229,12 +1229,10 @@ export const en = { designer: { title: 'Certificate designer', - subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.', + subtitle: 'Design the certificate issued to licence holders.', licenceType: 'Licence type', validityYears: 'Valid for (years)', - validityHint: 'Applied when a licence is issued', - saveValidity: 'Save validity', - validitySaved: 'Validity updated', + validityEditedOn: '— set on Certificate Requirements → Behaviour', newVersion: 'New version', versions: 'Versions', name: 'Version name', @@ -1301,7 +1299,14 @@ export const en = { registrationKind: 'Registration', eligibility: 'Eligibility gates', eligibilityHint: - 'Checked when an applicant submits. Tightening a gate can stop someone mid-way from submitting a draft they have already started.', + '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.', + capitalThreshold: 'Minimum paid-up capital', + capitalOn: 'Require a minimum paid-up capital', + capitalHint: + 'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.', + validityYears: 'Valid for (years)', + validityHint: + 'Applied when a licence is issued. Licences already issued keep the expiry date they were given.', requiresSeafarer: 'Requires an active seafarer registration', requiresSeafarerHint: 'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.', @@ -1312,7 +1317,7 @@ export const en = { certificateCategoryHint: 'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.', notACertificate: 'Not a certificate', - renewal: 'Renewal', + renewal: 'Validity and renewal', renewalWindow: 'Renewal opens (days before expiry)', reminders: 'Expiry reminders (days before)', remindersHint: 'The holder is reminded at each of these offsets.', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index e4ffc9770..e1a56677a 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -226,6 +226,8 @@ export const licensingApi = baseApi requiresSeafarerRegistration?: boolean; requiresValidMedical?: boolean; minSeaTimeDays?: number | null; + validityMonths?: number; + capitalThreshold?: number | null; renewalWindowDays?: number; expiryReminderDays?: number[]; requiresOperatorMode?: boolean; From e25a786139f843caee1ff0e3cb5232f8e7c31691 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 29 Aug 2026 08:41:08 +0000 Subject: [PATCH 17/21] feat: add support for license validity defined in days alongside months --- .../components/DesignerToolbar.tsx | 11 ++- .../pages/CertificateDesignerPage.tsx | 1 + .../components/BehaviorTab.tsx | 67 +++++++++++++------ apps/backoffice/src/app/i18n/locales/am.ts | 11 ++- apps/backoffice/src/app/i18n/locales/en.ts | 11 ++- .../lib/features/licensing/licensing-api.ts | 1 + .../lib/features/licensing/licensing.types.ts | 5 ++ 7 files changed, 82 insertions(+), 25 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx index 3a8296005..4ab644bc6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -14,6 +14,8 @@ interface Props { onRankChange: (id: string | null) => void; /** Shown for context only; edited on Certificate Requirements → Behaviour. */ validityMonths: number; + /** Non-null when the type is configured in days rather than months. */ + validityDays?: number | null; canEdit: boolean; onNewVersion: () => void; } @@ -27,6 +29,7 @@ export function DesignerToolbar({ rankId, onRankChange, validityMonths, + validityDays, canEdit, onNewVersion, }: Props) { @@ -75,11 +78,15 @@ export function DesignerToolbar({ {typeId && ( - {t('designer.validityYears', 'Valid for (years)')} + {t('designer.validity', 'Valid for')} - {Number((validityMonths / 12).toFixed(2))} + {validityDays != null + ? t('designer.validityDays', '{{count}} days', { count: validityDays }) + : t('designer.validityMonths', '{{count}} months', { + count: validityMonths, + })} {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index 8f5597f49..b46d68689 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -163,6 +163,7 @@ export function CertificateDesignerPage() { draft.setSelectedId(null); }} validityMonths={selectedType?.validityMonths ?? 12} + validityDays={selectedType?.validityDays ?? null} canEdit={canEdit} onNewVersion={startNewVersion} /> diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx index 6acfd90b4..d756d1d38 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -38,6 +38,7 @@ interface Draft { minSeaTimeDays: number | null; capitalThreshold: number | null; validityMonths: number; + validityDays: number | null; renewalWindowDays: number; expiryReminderDays: number[]; requiresOperatorMode: boolean; @@ -66,6 +67,7 @@ function toDraft(licenseType: LicenseType): Draft { ? null : Number(licenseType.capitalThreshold), validityMonths: licenseType.validityMonths ?? 12, + validityDays: licenseType.validityDays ?? null, renewalWindowDays: licenseType.renewalWindowDays ?? 60, expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], requiresOperatorMode: licenseType.requiresOperatorMode ?? true, @@ -266,25 +268,52 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
- {/* Stored in months, edited in years: a licence term is a number of - years to everyone who works with one. Half-years stay expressible. */} - - set('validityMonths', Math.round(Number(v || 0) * 12) || draft.validityMonths) - } - min={0.5} - max={20} - step={0.5} - decimalScale={1} - allowNegative={false} - disabled={!canEdit} - /> + {/* Amount + unit rather than a years box that silently divides by 12: + the seed says `validityMonths: 12` and this now says "12 Months", + so the two read the same. Months advance the calendar (issued on + the 31st, expires on the 31st); days are for terms shorter than a + month can express. */} + + { + const next = typeof v === 'number' ? v : 0; + if (!next) return; + if (draft.validityDays !== null) set('validityDays', next); + else set('validityMonths', next); + }} + // Matches the server's ranges, so the box cannot offer a value the + // save would reject: 1–3650 days, or 6–240 months. + min={draft.validityDays !== null ? 1 : 6} + max={draft.validityDays !== null ? 3650 : 240} + allowNegative={false} + disabled={!canEdit} + flex={1} + /> + - set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)} - min={1} - max={365} - allowNegative={false} + 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} /> - - set( - 'expiryReminderDays', - values.map(Number).sort((a, b) => b - a), - ) - } - disabled={!canEdit} - clearable - /> + {/* 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 + /> + + )}
@@ -367,6 +404,17 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { 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)} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index f3b3abfac..b5e96ace4 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1292,6 +1292,15 @@ export const am: Translations = { requiresExamination: "ፈተና ያስፈልገዋል", requiresExaminationHint: "ማመልከቻውን በብቁነት፣ ከዚያ በፈተና፣ ከዚያ በምስክር ወረቀት ሂደት ያሳልፋል። ሦስቱን የደረጃ ክፍያዎች በክፍያ ቅንብር ገጽ ላይ ያስቀምጡ።", + issuesCertificate: "የምስክር ወረቀት ይሰጣል", + issuesCertificateHint: + "በኢማ ውሳኔ ተጠናቆ ወደ ክፍያ ደረጃ ለማይደርስ ዓይነት ያጥፉ። እንደገና ማብራት የአዲስ ማመልከቻ ክፍያ እንዲዘጋጅ ይጠይቃል፤ አለበለዚያ የጸደቁ አመልካቾች የሌለ ክፍያ እንዲከፍሉ ይጠየቃሉ።", + inspectionRequired: "አካላዊ ምርመራ ያስፈልገዋል", + inspectionRequiredHint: + "ምርመራ ተመዝግቦ እስኪያልፍ ድረስ ኃላፊው ማጽደቅ አይችልም። ማመልከቻዎች በሂደት ላይ እያሉ መቀየር አስተማማኝ ነው — ኃላፊው በግምገማ ላይ ላለ ማመልከቻ አሁንም መርማሪ መመደብ ይችላል።", + renewalEnabled: "ባለቤቶች ይህንን ፈቃድ ማደስ ይችላሉ", + renewalEnabledHint: + "አንድ ጊዜ ብቻ ለሚሰጡ ያጥፉ — ጊዜው ለማያልፍ ምዝገባ፣ ወይም ለአንድ ጭነት ለተጻፈ ነፃ ፈቃድ።", serviceKind: "የአገልግሎት ዓይነት", serviceKindHint: "ለዝርዝር ምድብ ብቻ — ምንም የሥራ ሂደት በእሱ ላይ አይመሠረትም።", license: "ፈቃድ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index c09503dbf..585e965ad 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -1297,6 +1297,15 @@ export const en = { requiresExamination: 'Requires an examination', requiresExaminationHint: 'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.', + issuesCertificate: 'Issues a certificate', + issuesCertificateHint: + 'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.', + inspectionRequired: 'Requires a physical inspection', + 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.', + renewalEnabled: 'Holders may renew this licence', + renewalEnabledHint: + 'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.', serviceKind: 'Service kind', serviceKindHint: 'Catalogue classification only — no workflow depends on it.', license: 'Licence', diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index f0dcf3534..a15e2a45a 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -223,6 +223,9 @@ export const licensingApi = baseApi completionEffect?: CompletionEffect | null; certificateCategory?: CertificateCategory | null; requiresExamination?: boolean; + inspectionRequired?: boolean; + issuesCertificate?: boolean; + renewalEnabled?: boolean; requiresSeafarerRegistration?: boolean; requiresValidMedical?: boolean; minSeaTimeDays?: number | null; From 317a1532b12267bd96a394fc68b08fb40fd6c0b8 Mon Sep 17 00:00:00 2001 From: estifanos Date: Mon, 31 Aug 2026 08:18:27 +0000 Subject: [PATCH 19/21] feat: implement auto-filling of license and registration document slots from personal vault and add UI labels for vault-sourced files --- .../DocumentRequirementEditorDrawer.tsx | 27 ++++++- .../components/DocumentsTab.tsx | 9 +++ apps/backoffice/src/app/i18n/locales/am.ts | 6 ++ apps/backoffice/src/app/i18n/locales/en.ts | 6 ++ .../licensing/components/DocumentSlots.tsx | 65 +++++++++++------ .../pages/LicenseApplicationPage.tsx | 44 +++++++++++- .../components/RegistrationDocuments.tsx | 72 ++++++++++++++----- .../components/RegistrationSummary.tsx | 8 ++- .../pages/SeafarerRegistrationPage.tsx | 62 ++++++++++++++-- apps/portal/src/app/i18n/locales/am.ts | 1 + apps/portal/src/app/i18n/locales/en.ts | 1 + .../lib/features/licensing/licensing-api.ts | 32 +++++++++ .../lib/features/licensing/licensing.types.ts | 6 ++ 13 files changed, 288 insertions(+), 51 deletions(-) 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 9569913bc..387e1e5ba 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + Alert, Button, Checkbox, Divider, @@ -12,6 +13,7 @@ import { Text, TextInput, } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { BilingualInput, ModalFooter } from '@ema-platform/ui'; import { @@ -225,6 +227,20 @@ export function DocumentRequirementEditorDrawer({ title={{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}} > + {personal && ( + } + title={t('certReq.doc.keyMatchTitle', 'Keys are what link this to a licence')} + > + {t( + 'certReq.doc.keyMatchBody', + 'An applicant who holds this document has it copied onto any licence application asking for a requirement with the same key. A key that matches nothing is still collected — it simply never fills a form by itself.', + )} + + )} + { const file = attachment.files?.[0]; + const fromVault = Boolean(attachment.copiedFromAttachmentId); const flagged = attachment.documentKey in flags; const verdict = verdictFor(attachment.documentKey); const pendingReject = attachment.documentKey in rejecting; @@ -199,6 +200,14 @@ export function DocumentsTab({ requirementByKey.get(attachment.documentKey)?.name, ) || attachment.documentKey} + {/* Filed from the applicant's own document vault rather + than uploaded against this application — worth knowing + when the same file turns up on several files. */} + {fromVault && ( + + {t("review.documents.fromVault", "From My Documents")} + + )} {verdict && ( (null); const [error, setError] = useState(null); - const [preview, setPreview] = useState<{ url: string; title: string } | null>( + const [preview, setPreview] = useState<{ + url: string; + title: string; + mimeType?: string | null; + } | null>( null, ); const resetRefs = useRef void>>({}); @@ -111,7 +115,8 @@ export function DocumentSlots({ {required.map((requirement) => { const existing = attachments.find((a) => a.documentKey === requirement.key); const uploaded = Boolean(existing?.files?.length); - const fileUrl = existing?.files?.[0]?.url; + const files = existing?.files ?? []; + const fromVault = Boolean(existing?.copiedFromAttachmentId); const flagRemark = flagged[requirement.key]; const locked = readOnly || @@ -154,18 +159,25 @@ export function DocumentSlots({ {t('licensing.documents.uploaded')} )} + {/* Filled from the applicant's own vault rather than + uploaded here — without saying so, a file they never + attached to this application looks like a mistake. */} + {fromVault && !flagRemark && ( + + {t('licensing.documents.fromVault')} + + )} {requirement.description && ( {localized(requirement.description)} )} - {existing?.files?.[0] && ( - - {existing.files[0].originalName} ·{' '} - {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB + {files.map((file) => ( + + {file.originalName} · {(Number(file.sizeBytes) / 1024).toFixed(0)} KB - )} + ))} {flagRemark && ( {t('licensing.documents.officerRemark', { name: flagRemark })} @@ -174,20 +186,26 @@ export function DocumentSlots({
- {fileUrl && ( - - )} + {files + .filter((file) => file.url) + .map((file, index) => ( + + ))} {!locked && ( { @@ -222,11 +240,12 @@ export function DocumentSlots({ ); })} - setPreview(null)} url={preview?.url ?? ''} title={preview?.title} + mimeType={preview?.mimeType} /> ); diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 2a75eeae5..16e46e7cb 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, @@ -44,6 +44,7 @@ import { useAddStaffMutation, useCreateApplicationMutation, useGetApplicationQuery, + useFillApplicationDocumentsFromVaultMutation, useGetAttachmentsQuery, useGetLicenseTypeRequirementsQuery, useGetMyVesselsQuery, @@ -430,6 +431,46 @@ export function LicenseApplicationPage() { if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1)); }, [active, steps.length]); + /** + * Fills the document slots from the applicant's own vault the first time + * they reach that step. + * + * Someone who already keeps their passport under My Documents should find it + * here rather than being asked to go and fetch the same file again. The + * server only fills empty slots, so this cannot overwrite anything they + * uploaded, and calling it twice costs one query. + */ + const [fillFromVault] = useFillApplicationDocumentsFromVaultMutation(); + const filledForApplication = useRef(null); + useEffect(() => { + const status = application?.status; + const editable = + status === "DRAFT" || + status === "RESUBMIT_REQUIRED" || + (status === "SUBMITTED" && !application?.assignedOfficerId); + if (steps[active]?.kind !== "documents" || !appId || !editable) return; + if (filledForApplication.current === appId) return; + filledForApplication.current = appId; + + fillFromVault(appId) + .unwrap() + .then((result) => { + // Nothing was copied: no need to disturb the queries. + if (result.filled.length) refetchAttachments(); + }) + // A vault that cannot be read is not a reason to block the form — the + // applicant can still upload by hand. + .catch(() => undefined); + }, [ + steps, + active, + appId, + application?.status, + application?.assignedOfficerId, + fillFromVault, + refetchAttachments, + ]); + if (loadingConfig || !config || !appId || !application) { return ; } @@ -578,6 +619,7 @@ export function LicenseApplicationPage() { const currentStep = steps[active]; + /** * Checks one step before moving past it. * diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx index e1fb715e3..cc3c19b21 100644 --- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx +++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx @@ -2,38 +2,69 @@ import { useRef, useState } from 'react'; import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } from '@mantine/core'; import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react'; import { - SEAFARER_REGISTRATION_DOCUMENTS, - isEthiopianNationality, + conditionHolds, uploadDocument, + useLocalized, type Attachment, + type DocumentRequirement, } from '@ema-platform/api'; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; -/** The document slots a registration asks for. */ -export function documentSlots(passportDeclared: boolean, nationality?: string | null) { - const ethiopian = isEthiopianNationality(nationality); - return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({ - ...d, - isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required, - })).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian)); +/** A configured requirement, with whether this applicant must supply it. */ +export type RegistrationSlot = DocumentRequirement & { isRequired: boolean }; + +/** + * The document slots a registration asks for. + * + * Configuration, not a constant: these are `document_requirements` rows + * against the SEAFARER_REGISTRATION licence type, the same ones the backoffice + * edits and the server validates against. A conditional requirement — the + * passport copy, wanted once a passport number is declared — is evaluated + * against the answers under `identity`, exactly as the server does it, so the + * wizard and the submit check can never disagree about what is being asked + * for. + */ +export function documentSlots( + requirements: DocumentRequirement[], + answers: Record, +): RegistrationSlot[] { + const context = { identity: answers, personal: answers } as Record< + string, + Record + >; + return requirements + .filter( + (requirement) => + requirement.mode !== 'CONDITIONAL' || + conditionHolds(requirement.conditionExpression, context), + ) + .map((requirement) => ({ + ...requirement, + isRequired: + requirement.mode === 'ALWAYS' || + (requirement.mode === 'CONDITIONAL' && + conditionHolds(requirement.conditionExpression, context)), + })); } export function RegistrationDocuments({ registrationId, - passportDeclared, - nationality, + requirements, + answers, attachments, readOnly, onUploaded, }: { registrationId: string; - passportDeclared: boolean; - nationality?: string | null; + requirements: DocumentRequirement[]; + /** The registration's answers, for evaluating conditional requirements. */ + answers: Record; attachments: Attachment[]; readOnly?: boolean; onUploaded: () => void; }) { + const localized = useLocalized(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const resetRefs = useRef void>>({}); @@ -66,9 +97,10 @@ export function RegistrationDocuments({ {error} )} - {documentSlots(passportDeclared, nationality).map((slot) => { + {documentSlots(requirements, answers).map((slot) => { const existing = attachments.find((a) => a.documentKey === slot.key); const uploaded = Boolean(existing?.files?.length); + const fromVault = Boolean(existing?.copiedFromAttachmentId); return ( - {slot.name} + {localized(slot.name)} {!slot.isRequired && ( @@ -95,10 +127,16 @@ export function RegistrationDocuments({ uploaded )} + {/* Copied from My Documents rather than uploaded here. */} + {fromVault && ( + + from My Documents + + )} {slot.description && ( - {slot.description} + {localized(slot.description)} )} {existing?.files?.[0] && ( @@ -119,7 +157,7 @@ export function RegistrationDocuments({ if (r) resetRefs.current[slot.key] = r; }} onChange={(file) => handle(slot.key, file)} - accept={slot.accept} + accept={slot.allowedMimeTypes?.join(',')} > {(props) => (