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,203 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useLocalized,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
type ApplicationKind,
|
||||
type DocumentRequirement,
|
||||
type LicenseType,
|
||||
} from '@ema-platform/api';
|
||||
import { collectConditionTargets } from '../config/schema-paths';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
|
||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
|
||||
|
||||
const MODE_COLOR: Record<DocumentRequirement['mode'], string> = {
|
||||
ALWAYS: 'blue',
|
||||
CONDITIONAL: 'violet',
|
||||
OPTIONAL: 'gray',
|
||||
};
|
||||
|
||||
/**
|
||||
* Document upload requirements for one licence type, grouped by application
|
||||
* kind (a new-application slot and its renewal counterpart are different
|
||||
* rows even when they share a key). Every edit is a real CRUD call the
|
||||
* moment the admin confirms it — there is no separate "save all" step here,
|
||||
* unlike the form schema tab's whole-document replace.
|
||||
*/
|
||||
export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const run = useRequirementActions();
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useGetDocumentRequirementsQuery();
|
||||
const { data: palette } = useGetFormSchemaPaletteQuery();
|
||||
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
|
||||
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
|
||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||
|
||||
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
|
||||
const requirements = useMemo(
|
||||
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
|
||||
[data, licenseType.id],
|
||||
);
|
||||
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);
|
||||
|
||||
async function handleSave(draft: Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>) {
|
||||
const ok = await run(
|
||||
() =>
|
||||
editorState?.requirement
|
||||
? updateRequirement({ id: editorState.requirement.id, ...draft }).unwrap()
|
||||
: createRequirement({ ...draft, licenseTypeId: licenseType.id }).unwrap(),
|
||||
editorState?.requirement
|
||||
? t('certReq.doc.updated', 'Document requirement updated')
|
||||
: t('certReq.doc.created', 'Document requirement added'),
|
||||
);
|
||||
if (ok) setEditorState(null);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
const ok = await run(
|
||||
() => deleteRequirement(deleteTarget.id).unwrap(),
|
||||
t('certReq.doc.deleted', 'Document requirement removed'),
|
||||
);
|
||||
if (ok) setDeleteTarget(null);
|
||||
}
|
||||
|
||||
if (isLoading) return <PageLoader label={t('certReq.doc.loading', 'Loading document requirements…')} height={300} />;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={t('certReq.doc.loadFailed', 'Could not load document requirements')}
|
||||
description={extractErrorMessage(error)}
|
||||
onRetry={() => refetch()}
|
||||
icon={IconAlertCircle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t(
|
||||
'certReq.doc.subtitle',
|
||||
'What an applicant must upload for this licence type, split by new application and renewal.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{KINDS.map((kind) => {
|
||||
const rows = requirements
|
||||
.filter((r) => r.applicationKind === kind)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<Card key={kind} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>
|
||||
{kind === 'NEW' ? t('certReq.doc.kindNew', 'New application') : t('certReq.doc.kindRenewal', 'Renewal')}
|
||||
</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditorState({ kind, requirement: null })}
|
||||
>
|
||||
{t('certReq.doc.add', 'Add document requirement')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
{t('certReq.doc.emptyKind', 'No document requirements for this application kind yet.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{rows.map((req) => (
|
||||
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap={6}>
|
||||
<Text fz="sm" fw={600} truncate>{localized(req.name) || req.key}</Text>
|
||||
<Badge size="xs" color={MODE_COLOR[req.mode]} variant="light">{req.mode}</Badge>
|
||||
{req.allowMultiple && <Badge size="xs" variant="outline">{t('certReq.doc.multiple', 'multiple')}</Badge>}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||
</Text>
|
||||
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && (
|
||||
<Text fz="xs" c="violet">
|
||||
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '}
|
||||
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
|
||||
{req.conditionExpression.notEquals !== undefined && `≠ ${req.conditionExpression.notEquals}`}
|
||||
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
|
||||
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => setEditorState({ kind, requirement: req })}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(req)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{requirements.length === 0 && (
|
||||
<EmptyState
|
||||
title={t('certReq.doc.empty', 'No document requirements configured')}
|
||||
description={t('certReq.doc.emptyBody', 'Add the documents an applicant must upload for this licence type.')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editorState !== null}
|
||||
onClose={() => setEditorState(null)}
|
||||
requirement={editorState?.requirement ?? null}
|
||||
defaultApplicationKind={editorState?.kind ?? 'NEW'}
|
||||
onSave={handleSave}
|
||||
palette={palette}
|
||||
conditionTargets={conditionTargets}
|
||||
saving={creating || updating}
|
||||
/>
|
||||
|
||||
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" variant="light">
|
||||
{t('certReq.doc.deleteWarning', 'Applicants already relying on this slot will no longer see it. This cannot be undone.')}
|
||||
</Alert>
|
||||
<Text fz="sm">
|
||||
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
|
||||
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>{t('certReq.cancel', 'Cancel')}</Button>
|
||||
<Button color="red" onClick={confirmDelete}>{t('certReq.delete', 'Delete')}</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user