mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 22:58:18 +00:00
feat: add document requirements and form schema management for license types
- Implement DocumentRequirementsTab for managing document upload requirements. - Create FieldEditorDrawer and SectionEditorDrawer for editing fields and sections in the form schema. - Develop FormSchemaTab to handle sections and fields for a license type's form schema. - Introduce CertificateRequirementsPage to serve as the main interface for configuring license type requirements. - Add hooks for requirement actions to streamline mutation handling and notifications. - Implement condition handling for fields and sections to manage visibility based on user input. - Create schema-paths configuration to facilitate condition target collection.
This commit is contained in:
@@ -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<T>(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<FormSectionConfig[]>(licenseType.formSchema.sections);
|
||||
const [issues, setIssues] = useState<SchemaIssue[] | null>(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<FormSectionConfig | null>(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<FormSectionConfig, 'fields'>) {
|
||||
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 (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t(
|
||||
'certReq.schema.subtitle',
|
||||
'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.',
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" loading={validating} onClick={checkForErrors}>
|
||||
{t('certReq.schema.checkErrors', 'Check for errors')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconPlus size={15} />}
|
||||
variant="default"
|
||||
onClick={() => setSectionDrawer({ section: null })}
|
||||
>
|
||||
{t('certReq.section.add', 'Add section')}
|
||||
</Button>
|
||||
<Button color="teal" loading={saving} disabled={!dirty} onClick={handleSave}>
|
||||
{t('certReq.schema.save', 'Save schema')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{issues !== null && issues.length > 0 && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t('certReq.schema.issuesFound', 'Issues found')}>
|
||||
<Stack gap={4}>
|
||||
{issues.map((issue, i) => (
|
||||
<Text key={i} fz="xs">
|
||||
<Text span fw={600}>{issue.path}</Text>: {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('certReq.schema.empty', 'No sections yet')}
|
||||
description={t('certReq.schema.emptyBody', 'Add a section to start building this licence type\'s form.')}
|
||||
action={{ label: t('certReq.section.add', 'Add section'), onClick: () => setSectionDrawer({ section: null }) }}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{sections.map((section, sIndex) => (
|
||||
<Card key={section.key} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={700}>{localized(section.title) || section.key}</Text>
|
||||
{section.group && <Badge size="xs" variant="light">{t('certReq.section.groupBadge', 'group')}: {section.group}</Badge>}
|
||||
{section.showWhen && <Badge size="xs" color="violet" variant="light">{t('certReq.condition.badge', 'conditional')}</Badge>}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">key: {section.key}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<ActionIcon variant="subtle" disabled={sIndex === 0} onClick={() => mutate(moveItem(sections, sIndex, -1))}>
|
||||
<IconChevronUp size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" disabled={sIndex === sections.length - 1} onClick={() => mutate(moveItem(sections, sIndex, 1))}>
|
||||
<IconChevronDown size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => setSectionDrawer({ section })}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteSection(section)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
{section.fields.map((field, fIndex) => (
|
||||
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz="sm" fw={600} truncate>{localized(field.label) || field.key}</Text>
|
||||
{field.required && <Text fz="xs" c="red" fw={700}>*</Text>}
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Badge size="xs" variant="light">{field.type}</Badge>
|
||||
<Text fz="xs" c="dimmed" truncate>key: {field.key}</Text>
|
||||
{field.showWhen && <Badge size="xs" color="violet" variant="light">{t('certReq.condition.badge', 'conditional')}</Badge>}
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon variant="subtle" size="sm" disabled={fIndex === 0} onClick={() => mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, -1) } : s)))}>
|
||||
<IconChevronUp size={13} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" size="sm" disabled={fIndex === section.fields.length - 1} onClick={() => mutate(sections.map((s) => (s.key === section.key ? { ...s, fields: moveItem(s.fields, fIndex, 1) } : s)))}>
|
||||
<IconChevronDown size={13} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" size="sm" color="blue" onClick={() => setFieldDrawer({ sectionKey: section.key, field })}>
|
||||
<IconEdit size={13} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" size="sm" color="red" onClick={() => setDeleteField({ sectionKey: section.key, field })}>
|
||||
<IconTrash size={13} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setFieldDrawer({ sectionKey: section.key, field: null })}
|
||||
>
|
||||
{t('certReq.field.add', 'Add field')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<SectionEditorDrawer
|
||||
opened={sectionDrawer !== null}
|
||||
onClose={() => setSectionDrawer(null)}
|
||||
section={sectionDrawer?.section ?? null}
|
||||
onSave={saveSection}
|
||||
palette={palette}
|
||||
conditionTargets={conditionTargets}
|
||||
/>
|
||||
|
||||
<FieldEditorDrawer
|
||||
opened={fieldDrawer !== null}
|
||||
onClose={() => setFieldDrawer(null)}
|
||||
field={fieldDrawer?.field ?? null}
|
||||
onSave={saveField}
|
||||
palette={palette}
|
||||
conditionTargets={conditionTargets}
|
||||
/>
|
||||
|
||||
<Modal opened={deleteSection !== null} onClose={() => setDeleteSection(null)} title={t('certReq.section.delete', 'Delete section')} size="sm">
|
||||
<Stack gap="md">
|
||||
<Text fz="sm">
|
||||
{t('certReq.section.deleteConfirm', 'Remove "{{name}}" and all of its fields from this schema?', {
|
||||
name: deleteSection ? localized(deleteSection.title) || deleteSection.key : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteSection(null)}>{t('certReq.cancel', 'Cancel')}</Button>
|
||||
<Button color="red" onClick={confirmDeleteSection}>{t('certReq.delete', 'Delete')}</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteField !== null} onClose={() => setDeleteField(null)} title={t('certReq.field.delete', 'Delete field')} size="sm">
|
||||
<Stack gap="md">
|
||||
<Text fz="sm">
|
||||
{t('certReq.field.deleteConfirm', 'Remove "{{name}}" from this section?', {
|
||||
name: deleteField ? localized(deleteField.field.label) || deleteField.field.key : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteField(null)}>{t('certReq.cancel', 'Cancel')}</Button>
|
||||
<Button color="red" onClick={confirmDeleteField}>{t('certReq.delete', 'Delete')}</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user