mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
201 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|