mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 21:50:58 +00:00
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
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<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
|
|
|
/**
|
|
* 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<DraftRequirement>(
|
|
emptyDraft(defaultApplicationKind, personal),
|
|
);
|
|
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
|
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
|
const [keyError, setKeyError] = useState<string | null>(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 (
|
|
<Drawer
|
|
opened={opened}
|
|
onClose={onClose}
|
|
position="right"
|
|
size="md"
|
|
title={<Text fw={700}>{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}</Text>}
|
|
>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label={t('certReq.doc.key', 'Key')}
|
|
placeholder="bank_letter"
|
|
required
|
|
value={draft.key}
|
|
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')}
|
|
// 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 }));
|
|
}}
|
|
/>
|
|
|
|
<BilingualInput
|
|
label={t('certReq.doc.name', 'Name')}
|
|
required
|
|
value={{ en: draft.name.en ?? '', am: draft.name.am ?? '' }}
|
|
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
|
|
/>
|
|
|
|
<BilingualInput
|
|
label={t('certReq.doc.description', 'Description')}
|
|
value={{ en: draft.description?.en ?? '', am: draft.description?.am ?? '' }}
|
|
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
|
|
/>
|
|
|
|
{personal && (
|
|
<Stack gap="xs">
|
|
<Text fz="sm" fw={500}>
|
|
{t('certReq.doc.scope', 'Applies to')}
|
|
</Text>
|
|
<SegmentedControl
|
|
fullWidth
|
|
value={appliesToAll ? 'all' : 'selected'}
|
|
onChange={(v) => setAppliesToAll(v === 'all')}
|
|
data={[
|
|
{ value: 'all', label: t('certReq.doc.scopeAll', 'All licences') },
|
|
{
|
|
value: 'selected',
|
|
label: t('certReq.doc.scopeSelected', 'Selected licence types'),
|
|
},
|
|
]}
|
|
/>
|
|
{!appliesToAll && (
|
|
<MultiSelect
|
|
data={licenseTypes
|
|
.slice()
|
|
.sort((a, b) => 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.',
|
|
)}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
|
|
{!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}
|
|
/>
|
|
)}
|
|
|
|
{!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}
|
|
/>
|
|
)}
|
|
|
|
{!personal && draft.mode === 'CONDITIONAL' && (
|
|
<>
|
|
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
|
<ConditionBuilder
|
|
value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
|
|
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
|
|
targets={conditionTargets}
|
|
palette={palette}
|
|
allowClear={false}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<MultiSelect
|
|
label={t('certReq.doc.allowedTypes', 'Allowed file types')}
|
|
description={t(
|
|
'certReq.doc.allowedTypesHelp',
|
|
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
|
|
)}
|
|
searchable
|
|
data={MIME_OPTIONS}
|
|
value={draft.allowedMimeTypes}
|
|
onChange={(v) => setDraft((d) => ({ ...d, allowedMimeTypes: v }))}
|
|
/>
|
|
|
|
<NumberInput
|
|
label={t('certReq.doc.maxSize', 'Max file size (MB)')}
|
|
min={1}
|
|
value={draft.maxSizeMb}
|
|
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
|
|
/>
|
|
|
|
{!personal && (
|
|
<Checkbox
|
|
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
|
|
checked={draft.requiresValidityDates}
|
|
onChange={(e) => {
|
|
const { checked } = e.currentTarget;
|
|
setDraft((d) => ({ ...d, requiresValidityDates: 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
|
|
label={t('certReq.doc.sortOrder', 'Sort order')}
|
|
value={draft.sortOrder}
|
|
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : d.sortOrder }))}
|
|
/>
|
|
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
|
|
<Button color="teal" loading={saving} onClick={save}>
|
|
{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.saveChanges', 'Save changes')}
|
|
</Button>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Drawer>
|
|
);
|
|
}
|