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 && (