import { useEffect, useState } from 'react'; import { Button, Checkbox, Divider, Drawer, MultiSelect, NumberInput, SegmentedControl, Select, Stack, Text, TextInput, } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { BilingualInput, ModalFooter } from '@ema-platform/ui'; 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)', }, ]; /** What a new slot accepts until someone widens it. */ const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; 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: '', name: { en: '', am: '' }, applicationKind, // 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. * * 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, requirement, defaultApplicationKind, onSave, palette, conditionTargets, saving, personal = false, licenseTypes = [], scope = [], }: { opened: boolean; onClose: () => void; /** Null = adding a new requirement. */ requirement: DocumentRequirement | null; defaultApplicationKind: ApplicationKind; 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 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; useEffect(() => { if (opened) { setDraft( requirement ? { key: requirement.key, name: { ...requirement.name }, description: requirement.description ? { ...requirement.description } : undefined, applicationKind: requirement.applicationKind, mode: requirement.mode, conditionExpression: requirement.conditionExpression, allowedMimeTypes: requirement.allowedMimeTypes, 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() { if (!draft.key.trim()) { setKeyError(t('certReq.doc.keyRequired', 'Key is required')); return; } if (!draft.name.en?.trim()) return; // A condition is either a single-field check or an anyOf list — the CoP // watch_rating_certificate requirement is seeded with anyOf and no field. const hasCondition = Boolean(draft.conditionExpression?.field) || Boolean(draft.conditionExpression?.anyOf?.length); if ( draft.mode === 'CONDITIONAL' && !hasCondition && !draft.conditionExpression?.anyOf?.length ) { setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition')); return; } 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 ( {isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}} > { const { value } = e.currentTarget; setDraft((d) => ({ ...d, key: value })); }} /> setDraft((d) => ({ ...d, name: 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 && ( v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} allowDeselect={false} /> )} {!personal && draft.mode === 'CONDITIONAL' && ( <> setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))} targets={conditionTargets} palette={palette} allowClear={false} /> )} setDraft((d) => ({ ...d, allowedMimeTypes: v }))} /> setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))} /> {!personal && ( { const { checked } = e.currentTarget; setDraft((d) => ({ ...d, requiresValidityDates: checked })); }} /> )} setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null })) } /> setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : d.sortOrder }))} /> ); }