mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 22:53:27 +00:00
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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';
|
|
import { StaffRolesCard } from './StaffRolesCard';
|
|
|
|
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-dimmed)" />
|
|
<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-default-hover)">
|
|
<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>
|
|
)}
|
|
|
|
<StaffRolesCard licenseType={licenseType} />
|
|
|
|
<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>
|
|
);
|
|
}
|