feat: add support for global personal document requirements in applicant vaults

This commit is contained in:
estifanos
2026-08-28 08:54:10 +00:00
parent 627fcfbfde
commit f255621672
15 changed files with 780 additions and 171 deletions

View File

@@ -25,21 +25,32 @@ const MIME_OPTIONS = [
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
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<DraftRequirement>(emptyDraft(defaultApplicationKind));
const [draft, setDraft] = useState<DraftRequirement>(
emptyDraft(defaultApplicationKind, personal),
);
const [keyError, setKeyError] = useState<string | null>(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 }))}
/>
<Select
label={t('certReq.doc.applicationKind', 'Application kind')}
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
]}
value={draft.applicationKind}
onChange={(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}
/>
{!personal && (
<Select
label={t('certReq.doc.applicationKind', 'Application kind')}
data={[
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
]}
value={draft.applicationKind}
onChange={(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}
/>
)}
<Select
label={t('certReq.doc.mode', 'Mode')}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
]}
value={draft.mode}
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
allowDeselect={false}
/>
{!personal && (
<Select
label={t('certReq.doc.mode', 'Mode')}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
]}
value={draft.mode}
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
allowDeselect={false}
/>
)}
{draft.mode === 'CONDITIONAL' && (
{!personal && draft.mode === 'CONDITIONAL' && (
<>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder
@@ -199,16 +223,26 @@ export function DocumentRequirementEditorDrawer({
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
/>
<Checkbox
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
checked={draft.requiresValidityDates}
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
/>
{!personal && (
<Checkbox
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
checked={draft.requiresValidityDates}
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
/>
)}
<Checkbox
label={t('certReq.doc.allowMultiple', 'Allow multiple uploads')}
checked={draft.allowMultiple}
onChange={(e) => setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))}
<NumberInput
label={t('certReq.doc.maxFiles', 'Files accepted')}
description={t(
'certReq.doc.maxFilesHelp',
'Leave empty for no limit. Use 2 for a document with a front and a back.',
)}
placeholder={t('certReq.doc.maxFilesUnlimited', 'No limit')}
min={1}
value={draft.maxFiles ?? ''}
onChange={(v) =>
setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null }))
}
/>
<NumberInput

View File

@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Modal,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import {
useCreateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetDocumentRequirementsQuery,
useLocalized,
useUpdateDocumentRequirementMutation,
type DocumentRequirement,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
/**
* Documents every applicant keeps, whatever they apply for.
*
* These are the same `document_requirements` rows as the per-licence-type tab
* below, minus the licence type: a row with none is not a slot on one
* application form but a slot in the applicant's own document vault, which
* the portal shows under My Documents. Kept out of the licence-type tab
* deliberately — a document that belongs to everybody reads wrong filed under
* whichever type happened to be selected.
*/
export function PersonalDocumentsCard() {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data } = useGetDocumentRequirementsQuery();
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
const [editing, setEditing] = useState<{ requirement: DocumentRequirement | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
const rows = useMemo(
() =>
(data?.items ?? [])
.filter((r) => r.licenseTypeId === null)
.sort((a, b) => a.sortOrder - b.sortOrder),
[data],
);
async function handleSave(draft: Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>) {
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 (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb={4} wrap="nowrap">
<Title order={5}>{t('certReq.personal.title', 'Documents required for every licence')}</Title>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditing({ requirement: null })}
>
{t('certReq.personal.add', 'Add personal document')}
</Button>
</Group>
<Text fz="sm" c="dimmed" mb="sm">
{t(
'certReq.personal.subtitle',
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application.',
)}
</Text>
{rows.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
{t('certReq.personal.empty', 'No personal documents configured yet.')}
</Text>
) : (
<Stack gap="xs">
{rows.map((req) => (
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Group gap={6}>
<Text fz="sm" fw={600} truncate>
{localized(req.name) || req.key}
</Text>
<Badge size="xs" variant="light">
{req.maxFiles === null
? t('certReq.doc.maxFilesUnlimited', 'No limit')
: t('certReq.personal.fileCount', '{{count}} file', { count: req.maxFiles })}
</Badge>
</Group>
<Text fz="xs" c="dimmed" truncate>
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => setEditing({ requirement: req })}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(req)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<DocumentRequirementEditorDrawer
opened={editing !== null}
onClose={() => setEditing(null)}
requirement={editing?.requirement ?? null}
defaultApplicationKind="NEW"
onSave={handleSave}
palette={undefined}
conditionTargets={[]}
saving={creating || updating}
personal
/>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t('certReq.doc.delete', 'Delete document requirement')}
size="sm"
>
<Stack gap="md">
<Alert color="yellow" variant="light">
{t(
'certReq.personal.deleteWarning',
'The slot disappears from every applicants My Documents. Files already uploaded are kept, but nobody can reach them.',
)}
</Alert>
<Text fz="sm">
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>
{t('certReq.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={confirmDelete}>
{t('certReq.delete', 'Delete')}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Card>
);
}

View File

@@ -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() {
<PageLoader label={t('certReq.loading', 'Loading licence types…')} height={300} />
) : (
<Stack gap="md">
{/* 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. */}
<PersonalDocumentsCard />
<Select
label={t('certReq.licenseType', 'Licence type')}
placeholder={t('certReq.selectLicenseType', 'Select a licence type')}