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

201 lines
8.3 KiB
TypeScript

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 { describeCondition } from './ConditionBuilder';
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 && (
<Text fz="xs" c="violet">
{t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
</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>
);
}