Files
emaui/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx

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>
);
}