diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx index 1efa584e7..9569913bc 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -6,6 +6,7 @@ import { Drawer, MultiSelect, NumberInput, + SegmentedControl, Select, Stack, Text, @@ -13,33 +14,103 @@ import { } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { BilingualInput, ModalFooter } from '@ema-platform/ui'; -import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api'; +import { + useLocalized, + type ApplicationKind, + type DocumentRequirement, + type FormSchemaPalette, + type LicenseType, +} from '@ema-platform/api'; import { ConditionBuilder, type ConditionValue } from './ConditionBuilder'; import type { ConditionTarget } from '../config/schema-paths'; +/** + * File types a slot may be opened to. + * + * Longer than the three a slot starts with, because what an applicant + * actually has is not always a scan: a phone photographs an ID as HEIC, a + * scanner writes multi-page TIFF, and an academic record often arrives as the + * Word file its institution issued. Widening a slot stays a deliberate choice + * — the defaults below do not change — but it no longer needs a release. + */ const MIME_OPTIONS = [ { value: 'application/pdf', label: 'PDF' }, { value: 'image/jpeg', label: 'JPEG' }, { value: 'image/png', label: 'PNG' }, + { value: 'image/webp', label: 'WebP' }, + { value: 'image/heic', label: 'HEIC (iPhone photo)' }, + { value: 'image/heif', label: 'HEIF' }, + { value: 'image/tiff', label: 'TIFF (scan)' }, + { value: 'image/bmp', label: 'BMP' }, + { value: 'application/msword', label: 'Word (.doc)' }, + { + value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + label: 'Word (.docx)', + }, + { value: 'application/vnd.ms-excel', label: 'Excel (.xls)' }, + { + value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + label: 'Excel (.xlsx)', + }, + { value: 'text/csv', label: 'CSV' }, + { value: 'text/plain', label: 'Plain text (.txt)' }, + // Video is measured in hundreds of megabytes, not the 5 MB a slot starts + // with: raise "Max file size" on any slot that accepts one. + { value: 'video/mp4', label: 'Video (.mp4)' }, + { value: 'video/quicktime', label: 'Video (.mov, iPhone)' }, + { value: 'video/webm', label: 'Video (.webm)' }, + { value: 'video/x-msvideo', label: 'Video (.avi)' }, + { value: 'audio/mpeg', label: 'Audio (.mp3)' }, + { value: 'audio/wav', label: 'Audio (.wav)' }, + // Both, because the same .m4a is reported as audio/mp4 by Chrome and + // audio/x-m4a by Safari; picking one would reject half the recordings. + { value: 'audio/mp4', label: 'Audio (.m4a)' }, + { value: 'audio/x-m4a', label: 'Audio (.m4a, Safari)' }, + { value: 'audio/ogg', label: 'Audio (.ogg)' }, ]; +/** What a new slot accepts until someone widens it. */ +const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; + type DraftRequirement = Omit; -function emptyDraft(applicationKind: ApplicationKind): DraftRequirement { +/** + * Which licences a personal document is asked for. + * + * Empty means every licence (stored as a row with no licence type); otherwise + * one row per chosen type, all sharing the key. The applicant sees one slot + * either way — the portal collapses the rows by key — and only if they have + * declared operating as one of the types. + */ +export type PersonalScope = string[]; + +function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement { return { key: '', name: { en: '', am: '' }, applicationKind, - mode: 'ALWAYS', - allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'], + // A personal document is never demanded by one application, so "always + // required" would be a promise nothing here can keep. + mode: personal ? 'OPTIONAL' : 'ALWAYS', + allowedMimeTypes: [...DEFAULT_MIME_TYPES], maxSizeMb: 5, requiresValidityDates: false, allowMultiple: false, + isPersonal: personal, + maxFiles: personal ? 1 : null, sortOrder: 0, }; } -/** Adds/edits one document requirement slot for a licence type + application kind. */ +/** + * Adds/edits one document requirement slot. + * + * Two shapes of the same row: a slot on one licence type's application form, + * and — with `personal` — a document every applicant keeps in their own vault + * whatever they apply for. The vault has no application to condition on and no + * renewal of its own, so those fields are hidden rather than left to mean + * nothing. + */ export function DocumentRequirementEditorDrawer({ opened, onClose, @@ -49,19 +120,33 @@ export function DocumentRequirementEditorDrawer({ palette, conditionTargets, saving, + personal = false, + licenseTypes = [], + scope = [], }: { opened: boolean; onClose: () => void; /** Null = adding a new requirement. */ requirement: DocumentRequirement | null; defaultApplicationKind: ApplicationKind; - onSave: (draft: DraftRequirement) => void; + onSave: (draft: DraftRequirement, scope: PersonalScope) => void; palette: FormSchemaPalette | undefined; conditionTargets: ConditionTarget[]; saving: boolean; + /** Editing a personal document — one kept in the applicant's own vault. */ + personal?: boolean; + /** Licence types offered as scope; only read when `personal`. */ + licenseTypes?: LicenseType[]; + /** The licence types this document is already scoped to. */ + scope?: PersonalScope; }) { const { t } = useTranslation(); - const [draft, setDraft] = useState(emptyDraft(defaultApplicationKind)); + const localized = useLocalized(); + const [draft, setDraft] = useState( + emptyDraft(defaultApplicationKind, personal), + ); + const [scopeIds, setScopeIds] = useState(scope); + const [appliesToAll, setAppliesToAll] = useState(scope.length === 0); const [keyError, setKeyError] = useState(null); const isNew = !requirement; @@ -80,13 +165,19 @@ export function DocumentRequirementEditorDrawer({ maxSizeMb: requirement.maxSizeMb, requiresValidityDates: requirement.requiresValidityDates, allowMultiple: requirement.allowMultiple, + isPersonal: requirement.isPersonal ?? personal, + maxFiles: requirement.maxFiles ?? null, sortOrder: requirement.sortOrder, } - : emptyDraft(defaultApplicationKind), + : emptyDraft(defaultApplicationKind, personal), ); + setScopeIds(scope); + setAppliesToAll(scope.length === 0); setKeyError(null); } - }, [opened, requirement, defaultApplicationKind]); + // `scope` is a fresh array each render; the opened flag is what gates this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, requirement, defaultApplicationKind, personal]); function save() { if (!draft.key.trim()) { @@ -107,11 +198,22 @@ export function DocumentRequirementEditorDrawer({ setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition')); return; } - onSave({ - ...draft, - key: draft.key.trim(), - conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined, - }); + if (personal && !appliesToAll && scopeIds.length === 0) { + setKeyError(t('certReq.doc.scopeRequired', 'Choose at least one licence type')); + return; + } + onSave( + { + ...draft, + key: draft.key.trim(), + conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined, + isPersonal: personal, + // `allowMultiple` predates `maxFiles` and nothing reads it any more; + // kept in step so the two columns never contradict each other. + allowMultiple: draft.maxFiles !== 1, + }, + appliesToAll ? [] : scopeIds, + ); } return ( @@ -131,7 +233,13 @@ export function DocumentRequirementEditorDrawer({ error={keyError} disabled={!isNew} description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')} - onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + // The value is read out of the event first: a functional updater + // runs after React has released the event, so `currentTarget` is + // null by the time it would be read inside one. + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, key: value })); + }} /> setDraft((d) => ({ ...d, description: v }))} /> - v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} - allowDeselect={false} - /> + {!personal && ( + v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} + allowDeselect={false} + /> + )} + + {!personal && draft.mode === 'CONDITIONAL' && ( <> setDraft((d) => ({ ...d, allowedMimeTypes: v }))} @@ -199,16 +352,29 @@ export function DocumentRequirementEditorDrawer({ onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))} /> - setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))} - /> + {!personal && ( + { + const { checked } = e.currentTarget; + setDraft((d) => ({ ...d, requiresValidityDates: checked })); + }} + /> + )} - setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))} + + setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null })) + } /> (null); const requirements = useMemo( - () => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id), + // Personal documents can also name a licence type — they are asked for in + // the applicant's vault, not on this form, and are edited in Configuration. + () => + (data?.items ?? []).filter( + (r) => r.licenseTypeId === licenseType.id && !r.isPersonal, + ), [data, licenseType.id], ); const conditionTargets = collectConditionTargets(licenseType.formSchema.sections); diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx index 53cd32174..370ba0572 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx @@ -113,7 +113,10 @@ export function FieldEditorDrawer({ ? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key') : t('certReq.field.keyLocked', 'Key cannot change once created') } - onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, key: value })); + }} /> setDraft((d) => ({ ...d, required: e.currentTarget.checked }))} + onChange={(e) => { + const { checked } = e.currentTarget; + setDraft((d) => ({ ...d, required: checked })); + }} /> ; + +/** Filter value for "the documents every licence asks for". */ +const GLOBAL_ONLY = 'GLOBAL'; + +const SEARCH_DEBOUNCE_MS = 300; + +/** + * Badge colours for licence types. + * + * Red is left out: it reads as a problem, and a licence type is not one. + * Everything else the theme offers is in, because the point of the colour is + * telling two licence types apart at a glance. + */ +const SCOPE_COLORS = [ + 'blue', + 'grape', + 'teal', + 'orange', + 'violet', + 'cyan', + 'pink', + 'lime', + 'indigo', + 'green', + 'yellow', + 'gray', +]; + +/** Doubles the palette: the same hue, a visibly different badge. */ +const SCOPE_VARIANTS = ['light', 'outline'] as const; + +/** + * A colour per licence type, assigned by position in the catalogue. + * + * Hashing the id looked tidier and was wrong: eight buckets over sixteen + * licence types collide by the pigeonhole principle, so Vessel Registration + * and Freight Forwarder came out the same colour and the badge stopped + * carrying information. Walking the sorted catalogue instead gives every type + * a distinct colour until the palette runs out, and only then repeats a hue in + * the other variant — 24 distinct badges before any two can look alike. + * + * Sorted by `sortOrder` so the assignment is the same for every officer and + * survives a refresh; a type added later takes the next free style rather than + * reshuffling the ones already learned. + */ +function buildScopeStyles( + types: { id: string; sortOrder: number }[], +): Map { + const styles = new Map< + string, + { color: string; variant: (typeof SCOPE_VARIANTS)[number] } + >(); + types + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .forEach((type, index) => { + styles.set(type.id, { + color: SCOPE_COLORS[index % SCOPE_COLORS.length], + variant: + SCOPE_VARIANTS[ + Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length + ], + }); + }); + return styles; +} + +/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */ +const MIME_LABELS: Record = { + 'application/pdf': 'PDF', + 'application/msword': 'DOC', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX', + 'application/vnd.ms-excel': 'XLS', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX', +}; + +function shortMime(mime: string): string { + return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime; +} + +/** + * A document as the table renders it: what the server sent, plus the row id + * `AdvancedTable` keys on and the scope read off its rows. + * + * The grouping itself belongs to the server — a page of rows would split a + * document configured for three licence types across two pages and misreport + * the scope of both halves. + */ +interface PersonalDocumentGroup extends PersonalDocumentGroupDto { + /** The key doubles as the row id; one group is one document. */ + id: string; + /** Empty when the document applies to every licence. */ + scope: PersonalScope; +} + +/** + * Documents an applicant keeps in their own vault. + * + * Same `document_requirements` table as a licence type's upload slots, flagged + * `isPersonal`: these are not asked for on an application form but held once, + * under My Documents in the portal. A document can apply to every licence or + * only to the modes of operation an applicant has declared — a sea service + * book is worth asking a seafarer for and pointless for a freight forwarder. + */ +export function PersonalDocumentsCard() { + const { t, i18n } = useTranslation(); + const localized = useLocalized(); + const run = useRequirementActions(); + + const { data: licenseTypes } = useGetLicenseTypesQuery(); + const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation(); + const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation(); + const [deleteRequirement] = useDeleteDocumentRequirementMutation(); + + const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + /** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */ + const [licenseTypeFilter, setLicenseTypeFilter] = useState(null); + + const { pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({ + pageSize: 10, + }); + const [searchInput, setSearchInput] = useState(''); + // Typing must not fire a request per keystroke; same 300ms as the queue. + const [search] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS); + + // Every facet goes to the server: it filters and searches in SQL, groups the + // rows into documents, then pages the documents. + const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({ + search: search.trim() || undefined, + licenseTypeId: + licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY + ? licenseTypeFilter + : undefined, + globalOnly: licenseTypeFilter === GLOBAL_ONLY, + take: pageSize, + skip: pageIndex * pageSize, + locale: i18n.language === 'am' ? 'am' : 'en', + }); + + const groups = useMemo( + () => + (data?.items ?? []).map((group) => ({ + ...group, + id: group.key, + // A single row with no licence type means "every licence"; the two + // never coexist, because the editor writes one shape or the other. + scope: group.rows + .map((r) => r.licenseTypeId) + .filter((id): id is string => id !== null), + })), + [data], + ); + + /** Filters are the server's business now; an empty page is its answer. */ + const isFiltered = search.trim() !== '' || licenseTypeFilter !== null; + + function clearFilters() { + setSearchInput(''); + setLicenseTypeFilter(null); + setPageIndex(0); + } + + const scopeStyles = useMemo( + () => buildScopeStyles(licenseTypes?.items ?? []), + [licenseTypes], + ); + + const typeName = (id: string) => { + const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id); + return found ? localized(found.name) || found.key : id; + }; + + const columns = useMemo[]>( + () => [ + { + header: t('certReq.personal.columns.document', 'Document'), + label: t('certReq.personal.columns.document', 'Document'), + cell: ({ row }) => ( +
+ + {localized(row.original.rows[0].name) || row.original.key} + + + {row.original.key} + +
+ ), + }, + { + header: t('certReq.doc.scope', 'Applies to'), + label: t('certReq.doc.scope', 'Applies to'), + cell: ({ row }) => + row.original.scope.length === 0 ? ( + // Filled, where a licence type is outlined: "every licence" is a + // different kind of answer, not one more item in the same list. + + {t('certReq.doc.scopeAll', 'All licences')} + + ) : ( + + {row.original.scope.map((id) => { + // A type the catalogue no longer lists still needs a badge. + const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' }; + return ( + + {typeName(id)} + + ); + })} + + ), + }, + { + header: t('certReq.doc.maxFiles', 'Files accepted'), + label: t('certReq.doc.maxFiles', 'Files accepted'), + align: 'center', + cell: ({ row }) => + row.original.rows[0].maxFiles === null + ? t('certReq.doc.maxFilesUnlimited', 'No limit') + : row.original.rows[0].maxFiles, + }, + { + header: t('certReq.doc.allowedTypes', 'Allowed file types'), + label: t('certReq.doc.allowedTypes', 'Allowed file types'), + cell: ({ row }) => { + const types = row.original.rows[0].allowedMimeTypes ?? []; + return ( + // Twenty-odd mime types would own the row; the full list is one + // hover away instead. + + + {types.slice(0, 3).map(shortMime).join(', ')} + {types.length > 3 + ? t('certReq.personal.moreTypes', ' +{{count}} more', { + count: types.length - 3, + }) + : ''} + + + ); + }, + }, + { + header: t('certReq.doc.maxSize', 'Max file size (MB)'), + label: t('certReq.doc.maxSize', 'Max file size (MB)'), + align: 'center', + cell: ({ row }) => `${row.original.rows[0].maxSizeMb} MB`, + }, + { + header: '', + label: t('certReq.personal.columns.actions', 'Actions'), + align: 'right', + cell: ({ row }) => ( + + setEditing({ group: row.original })} + > + + + setDeleteTarget(row.original)} + > + + + + ), + }, + ], + // `typeName` and `scopeStyles` both close over the licence-type list. + // eslint-disable-next-line react-hooks/exhaustive-deps + [t, localized, licenseTypes, scopeStyles], + ); + + /** + * Saves the group as the set of rows it now means. + * + * The scope is edited as a whole, so the diff is the honest way to apply it: + * rows for licence types that were added get created, rows for types that + * were dropped get deleted, and everything still in scope is updated. A + * document moved to "all licences" collapses to a single row with none. + */ + async function handleSave(draft: DraftRequirement, scope: PersonalScope) { + const existing = editing?.group?.rows ?? []; + // `null` is a licence type here too — the one meaning "every licence". + const wanted: (string | null)[] = scope.length ? scope : [null]; + + const ok = await run(async () => { + const stale = existing.filter((row) => !wanted.includes(row.licenseTypeId)); + const kept = existing.filter((row) => wanted.includes(row.licenseTypeId)); + const added = wanted.filter( + (id) => !existing.some((row) => row.licenseTypeId === id), + ); + + await Promise.all([ + ...kept.map((row) => updateRequirement({ id: row.id, ...draft }).unwrap()), + ...added.map((licenseTypeId) => + createRequirement({ ...draft, licenseTypeId }).unwrap(), + ), + ...stale.map((row) => deleteRequirement(row.id).unwrap()), + ]); + }, editing?.group + ? t('certReq.doc.updated', 'Document requirement updated') + : t('certReq.doc.created', 'Document requirement added')); + + if (ok) setEditing(null); + } + + async function confirmDelete() { + if (!deleteTarget) return; + const ok = await run( + () => Promise.all(deleteTarget.rows.map((row) => deleteRequirement(row.id).unwrap())), + t('certReq.doc.deleted', 'Document requirement removed'), + ); + if (ok) setDeleteTarget(null); + } + + return ( + + +
+ {t('certReq.personal.title', 'Personal documents')} + + {t( + 'certReq.personal.subtitle', + 'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.', + )} + +
+ +
+ + {/* Facets, in the shape the licence-review queue uses. No date range: + a configuration row has no submission date to filter on. */} + + + } + value={searchInput} + onChange={(e) => { + const { value } = e.currentTarget; + setSearchInput(value); + setPageIndex(0); + }} + w={240} + /> +