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(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 : '', })}
); }