diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx new file mode 100644 index 000000000..8b3ead1fc --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/ConditionBuilder.tsx @@ -0,0 +1,220 @@ +import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api'; +import { useLocalized } from '@ema-platform/api'; +import type { ConditionTarget } from '../config/schema-paths'; + +/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */ +export type ConditionValue = FieldCondition & { previousDocExpired?: string }; + +type Operator = 'equals' | 'notEquals' | 'in' | 'isSet'; + +function operatorOf(condition: ConditionValue | undefined): Operator | null { + if (!condition) return null; + if (condition.isSet !== undefined) return 'isSet'; + if (condition.equals !== undefined) return 'equals'; + if (condition.notEquals !== undefined) return 'notEquals'; + if (condition.in !== undefined) return 'in'; + return null; +} + +/** Best-effort type for a raw stored value, so re-editing an existing + * condition renders a number input for a numeric value rather than text. */ +function coerce(raw: string, targetType: string | undefined): string | number | boolean { + if (targetType === 'BOOLEAN') return raw === 'true'; + if (targetType === 'NUMBER' || targetType === 'MONEY') { + const n = Number(raw); + return Number.isFinite(n) && raw.trim() !== '' ? n : raw; + } + return raw; +} + +/** + * Authors one `FieldCondition` (`showWhen` on a section/field, or + * `conditionExpression` on a document requirement). + * + * The field-path box autocompletes from every field already defined in this + * licence type's schema (`targets`); when the chosen path resolves to a + * SELECT field, the value picker switches to that field's own options + * instead of free text — the condition can only ever reference an answer + * that could actually be chosen. + */ +export function ConditionBuilder({ + value, + onChange, + targets, + palette, + allowClear = true, +}: { + value: ConditionValue | null; + onChange: (value: ConditionValue | null) => void; + targets: ConditionTarget[]; + palette: FormSchemaPalette | undefined; + /** Hide the "no condition" toggle — used where a condition is mandatory (CONDITIONAL document mode). */ + allowClear?: boolean; +}) { + const { t } = useTranslation(); + const localized = useLocalized(); + + const active = value !== null; + const operator = operatorOf(value ?? undefined) ?? 'equals'; + const target = targets.find((c) => c.path === value?.field); + const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet']; + + function setField(field: string) { + onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) }); + } + + function setOperator(next: Operator) { + if (!value?.field) return; + const base: ConditionValue = { field: value.field }; + if (next === 'isSet') base.isSet = true; + else if (next === 'in') base.in = []; + else if (next === 'notEquals') base.notEquals = ''; + else base.equals = ''; + onChange(base); + } + + function setValueRaw(raw: string) { + if (!value?.field) return; + const coerced = coerce(raw, target?.field.type); + if (operator === 'equals') onChange({ field: value.field, equals: coerced }); + else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced }); + } + + function setInValues(raws: string[]) { + if (!value?.field) return; + onChange({ + field: value.field, + in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], + }); + } + + return ( + + {allowClear && ( + onChange(e.currentTarget.checked ? { field: '', equals: '' } : null)} + /> + )} + + {active && ( + + c.path)} + value={value?.field ?? ''} + onChange={setField} + /> + + + ({ + value: o.value, + label: localized(o.label) || o.value, + }))} + value={String(value?.equals ?? value?.notEquals ?? '')} + onChange={(v) => v !== null && setValueRaw(v)} + /> + )} + + {operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && ( + target?.field.type === 'BOOLEAN' ? ( + setValueRaw(String(e.currentTarget.checked))} + /> + ) : ( + setValueRaw(e.currentTarget.value)} + /> + ) + )} + + {operator === 'in' && target?.field.type === 'SELECT' && ( + 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} + /> + + ({ value: f.type, label: f.type }))} + value={draft.type} + onChange={(v) => v && setDraft((d) => ({ ...d, type: v as FormFieldConfig['type'] }))} + allowDeselect={false} + /> + + setDraft((d) => ({ ...d, required: e.currentTarget.checked }))} + /> + + setDraft((d) => ({ ...d, placeholder: v }))} + /> + + setDraft((d) => ({ ...d, helpText: v }))} + /> + + {typeInfo?.supportsRange && ( + + setDraft((d) => ({ ...d, min: typeof v === 'number' ? v : undefined }))} + /> + setDraft((d) => ({ ...d, max: typeof v === 'number' ? v : undefined }))} + /> + + )} + + {typeInfo?.supportsMaxLength && ( + setDraft((d) => ({ ...d, maxLength: typeof v === 'number' ? v : undefined }))} + /> + )} + + {typeInfo?.supportsOptions && ( + + + {t('certReq.field.options', 'Options')} + + + {(draft.options ?? []).map((o, i) => ( + + updateOption(i, { value: e.currentTarget.value })} + style={{ flex: 1 }} + /> + updateOption(i, { label: v })} + style={{ flex: 2 }} + /> + + + ))} + + )} + + + setDraft((d) => ({ ...d, showWhen: v ?? undefined }))} + targets={conditionTargets} + palette={palette} + /> + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx new file mode 100644 index 000000000..ddeb712f1 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx @@ -0,0 +1,334 @@ +import { useEffect, useState } from 'react'; +import { + ActionIcon, + Alert, + Badge, + Button, + Card, + Group, + Modal, + Stack, + Text, +} from '@mantine/core'; +import { + IconAlertTriangle, + IconChevronDown, + IconChevronUp, + IconEdit, + IconGripVertical, + IconPlus, + IconTrash, +} from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { EmptyState, ModalFooter, notify } from '@ema-platform/ui'; +import { + extractErrorMessage, + useGetFormSchemaPaletteQuery, + useLocalized, + useUpdateFormSchemaMutation, + useValidateFormSchemaMutation, + type FormFieldConfig, + type FormSectionConfig, + type LicenseType, + type SchemaIssue, +} from '@ema-platform/api'; +import { collectConditionTargets } from '../config/schema-paths'; +import { useRequirementActions } from '../hooks/useRequirementActions'; +import { FieldEditorDrawer } from './FieldEditorDrawer'; +import { SectionEditorDrawer } from './SectionEditorDrawer'; + +function moveItem(list: T[], index: number, direction: -1 | 1): T[] { + const target = index + direction; + if (target < 0 || target >= list.length) return list; + const next = [...list]; + [next[index], next[target]] = [next[target], next[index]]; + return next.map((item, i) => ({ ...item, sortOrder: i } as T)); +} + +/** + * Sections/fields editor for one licence type's `formSchema`. + * + * Edits build up a local draft; nothing is sent until "Save schema" — the + * server replaces the whole `formSchema` in one `PUT`, so partial saves would + * not match what the API accepts anyway. "Check for errors" dry-runs the same + * lint the save uses, so an author can fix problems before committing. + */ +export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const localized = useLocalized(); + const run = useRequirementActions(); + + const { data: palette } = useGetFormSchemaPaletteQuery(); + const [saveSchema, { isLoading: saving }] = useUpdateFormSchemaMutation(); + const [validateSchema, { isLoading: validating }] = useValidateFormSchemaMutation(); + + const [sections, setSections] = useState(licenseType.formSchema.sections); + const [issues, setIssues] = useState(null); + const [dirty, setDirty] = useState(false); + + // A newly selected licence type replaces the draft outright. Deliberately + // keyed on the id alone: a background refetch of the *same* type (e.g. the + // list tag invalidation right after this tab's own save) must not clobber + // whatever the admin is mid-editing. + useEffect(() => { + setSections(licenseType.formSchema.sections); + setIssues(null); + setDirty(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [licenseType.id]); + + const [sectionDrawer, setSectionDrawer] = useState<{ section: FormSectionConfig | null } | null>(null); + const [fieldDrawer, setFieldDrawer] = useState<{ sectionKey: string; field: FormFieldConfig | null } | null>(null); + const [deleteSection, setDeleteSection] = useState(null); + const [deleteField, setDeleteField] = useState<{ sectionKey: string; field: FormFieldConfig } | null>(null); + + const conditionTargets = collectConditionTargets(sections); + + function mutate(next: FormSectionConfig[]) { + setSections(next); + setDirty(true); + setIssues(null); + } + + function saveSection(meta: Omit) { + const editing = sectionDrawer?.section; + if (editing) { + mutate(sections.map((s) => (s.key === editing.key ? { ...s, ...meta } : s))); + } else { + mutate([...sections, { ...meta, fields: [] }]); + } + setSectionDrawer(null); + } + + function saveField(field: FormFieldConfig) { + if (!fieldDrawer) return; + mutate( + sections.map((s) => { + if (s.key !== fieldDrawer.sectionKey) return s; + const exists = fieldDrawer.field; + return { + ...s, + fields: exists + ? s.fields.map((f) => (f.key === exists.key ? field : f)) + : [...s.fields, field], + }; + }), + ); + setFieldDrawer(null); + } + + function confirmDeleteSection() { + if (!deleteSection) return; + mutate(sections.filter((s) => s.key !== deleteSection.key)); + setDeleteSection(null); + } + + function confirmDeleteField() { + if (!deleteField) return; + mutate( + sections.map((s) => + s.key === deleteField.sectionKey + ? { ...s, fields: s.fields.filter((f) => f.key !== deleteField.field.key) } + : s, + ), + ); + setDeleteField(null); + } + + async function checkForErrors() { + try { + const result = await validateSchema({ + formSchema: { sections }, + licenseTypeId: licenseType.id, + }).unwrap(); + setIssues(result.issues); + if (result.valid) notify.success(t('certReq.schema.noIssues', 'No issues found')); + } catch (err) { + notify.error(extractErrorMessage(err)); + } + } + + async function handleSave() { + const ok = await run( + () => saveSchema({ id: licenseType.id, formSchema: { sections } }).unwrap(), + t('certReq.schema.saved', 'Form schema saved'), + ); + if (ok) { + setDirty(false); + setIssues(null); + } + } + + return ( + + + + {t( + 'certReq.schema.subtitle', + 'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.', + )} + + + + + + + + + {issues !== null && issues.length > 0 && ( + } title={t('certReq.schema.issuesFound', 'Issues found')}> + + {issues.map((issue, i) => ( + + {issue.path}: {issue.message} + + ))} + + + )} + + {sections.length === 0 ? ( + setSectionDrawer({ section: null }) }} + /> + ) : ( + + {sections.map((section, sIndex) => ( + + + + +
+ + {localized(section.title) || section.key} + {section.group && {t('certReq.section.groupBadge', 'group')}: {section.group}} + {section.showWhen && {t('certReq.condition.badge', 'conditional')}} + + key: {section.key} +
+
+ + mutate(moveItem(sections, sIndex, -1))}> + + + mutate(moveItem(sections, sIndex, 1))}> + + + setSectionDrawer({ section })}> + + + setDeleteSection(section)}> + + + +
+ + + {section.fields.map((field, fIndex) => ( + + + +
+ + {localized(field.label) || field.key} + {field.required && *} + + + {field.type} + key: {field.key} + {field.showWhen && {t('certReq.condition.badge', 'conditional')}} + +
+
+ + mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, -1) } : s)))}> + + + mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, 1) } : s)))}> + + + setFieldDrawer({ sectionKey: section.key, field })}> + + + setDeleteField({ sectionKey: section.key, field })}> + + + +
+
+ ))} + + +
+
+ ))} +
+ )} + + setSectionDrawer(null)} + section={sectionDrawer?.section ?? null} + onSave={saveSection} + palette={palette} + conditionTargets={conditionTargets} + /> + + setFieldDrawer(null)} + field={fieldDrawer?.field ?? null} + onSave={saveField} + palette={palette} + conditionTargets={conditionTargets} + /> + + setDeleteSection(null)} title={t('certReq.section.delete', 'Delete section')} size="sm"> + + + {t('certReq.section.deleteConfirm', 'Remove "{{name}}" and all of its fields from this schema?', { + name: deleteSection ? localized(deleteSection.title) || deleteSection.key : '', + })} + + + + + + + + + setDeleteField(null)} title={t('certReq.field.delete', 'Delete field')} size="sm"> + + + {t('certReq.field.deleteConfirm', 'Remove "{{name}}" from this section?', { + name: deleteField ? localized(deleteField.field.label) || deleteField.field.key : '', + })} + + + + + + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx new file mode 100644 index 000000000..6110b8358 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import { Button, Divider, Drawer, NumberInput, Stack, Text, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { BilingualInput, ModalFooter } from '@ema-platform/ui'; +import type { FormSchemaPalette, FormSectionConfig } from '@ema-platform/api'; +import { ConditionBuilder, type ConditionValue } from './ConditionBuilder'; +import type { ConditionTarget } from '../config/schema-paths'; + +const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +function emptySection(): FormSectionConfig { + return { key: '', title: { en: '', am: '' }, fields: [] }; +} + +/** Adds/edits one section's own metadata — its fields are managed on the list, not here. */ +export function SectionEditorDrawer({ + opened, + onClose, + section, + onSave, + palette, + conditionTargets, +}: { + opened: boolean; + onClose: () => void; + /** Null = adding a new section. */ + section: FormSectionConfig | null; + onSave: (section: Omit) => void; + palette: FormSchemaPalette | undefined; + conditionTargets: ConditionTarget[]; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(emptySection()); + const [keyError, setKeyError] = useState(null); + const isNew = !section; + + useEffect(() => { + if (opened) { + setDraft(section ? { ...section, title: { ...section.title } } : emptySection()); + setKeyError(null); + } + }, [opened, section]); + + function save() { + if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) { + setKeyError(t('certReq.section.keyInvalid', 'Key must start with a letter and contain only letters, numbers, underscores')); + return; + } + if (!draft.title.en?.trim()) return; + const { fields: _fields, ...meta } = draft; + onSave({ ...meta, key: draft.key.trim() }); + } + + return ( + {isNew ? t('certReq.section.add', 'Add section') : t('certReq.section.edit', 'Edit section')}} + > + + setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + /> + + setDraft((d) => ({ ...d, title: v }))} + /> + + setDraft((d) => ({ ...d, description: v }))} + /> + + setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))} + /> + + setDraft((d) => ({ ...d, groupOrder: typeof v === 'number' ? v : undefined }))} + /> + + setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : undefined }))} + /> + + + setDraft((d) => ({ ...d, showWhen: v ?? undefined }))} + targets={conditionTargets} + palette={palette} + /> + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/config/schema-paths.ts b/apps/backoffice/src/app/features/certificate-requirements/config/schema-paths.ts new file mode 100644 index 000000000..7375b75ca --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/config/schema-paths.ts @@ -0,0 +1,28 @@ +import type { FormFieldConfig, FormSectionConfig } from '@ema-platform/api'; + +/** One field reachable by a condition's dot path, with enough of its config + * to drive the value picker (SELECT offers its options; everything else is + * free text/number/boolean). */ +export interface ConditionTarget { + /** `${sectionKey}.${fieldKey}` — what `FieldCondition.field` expects. */ + path: string; + field: FormFieldConfig; +} + +/** + * Every field in the schema a condition could point at. + * + * Drives the condition builder's autocomplete and its options-aware value + * picker — typing `certificate.` suggests `certificate.rank` because that + * section/field exists in this licence type's own schema, not because the + * rank list is known anywhere in the frontend. + */ +export function collectConditionTargets(sections: FormSectionConfig[]): ConditionTarget[] { + const targets: ConditionTarget[] = []; + for (const section of sections) { + for (const field of section.fields ?? []) { + targets.push({ path: `${section.key}.${field.key}`, field }); + } + } + return targets; +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts b/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts new file mode 100644 index 000000000..e1c2337cd --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; +import { notifications } from '@mantine/notifications'; +import { useTranslation } from 'react-i18next'; +import { extractErrorMessage } from '@ema-platform/api'; + +/** + * Runs a mutation and reports the outcome once — the same "one path, every + * action" pattern as the certificate designer's `useDesignerActions`. + */ +export function useRequirementActions() { + const { t } = useTranslation(); + + return useCallback( + async (action: () => Promise, success: string) => { + try { + await action(); + notifications.show({ color: 'teal', title: success, message: '' }); + return true; + } catch (err) { + notifications.show({ + color: 'red', + title: t('certReq.actionFailed', 'Action failed'), + message: extractErrorMessage(err), + }); + return false; + } + }, + [t], + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx new file mode 100644 index 000000000..8e9414a0a --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/pages/CertificateRequirementsPage.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { Container, Select, Stack, Tabs } from '@mantine/core'; +import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +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'; + +/** + * Where an administrator configures what an applicant must fill in and + * upload for a licence type — the form's sections/fields (with visibility + * conditions) and its document upload slots (with conditional requirements). + * + * Not scoped to CoC/CoP specifically: every active licence type is offered, + * because a form schema and its document requirements are properties of any + * licence type, not just certificates. CoC/CoP are simply the first types an + * administrator is expected to configure this way. + */ +export function CertificateRequirementsPage() { + const { t } = useTranslation(); + const localized = useLocalized(); + + const { data: licenseTypes, isLoading, isError, error, refetch } = useGetLicenseTypesQuery(); + const [typeId, setTypeId] = useState(null); + + const options = (licenseTypes?.items ?? []) + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((lt) => ({ value: lt.id, label: `${localized(lt.name)} (${lt.key})` })); + + useEffect(() => { + if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); + }, [licenseTypes, typeId]); + + const selectedType = licenseTypes?.items?.find((lt) => lt.id === typeId); + + return ( + + + + + {isError ? ( + refetch()} + icon={IconAlertCircle} + /> + ) : isLoading ? ( + + ) : ( + +