From f255621672fe8383894718a9f0ed2470b013396b Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 08:54:10 +0000 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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], ); /**