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. */} + +