mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 13:05:46 +00:00
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
ScrollArea,
|
|
SegmentedControl,
|
|
Select,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAdjustments,
|
|
IconAlertCircle,
|
|
IconCheck,
|
|
IconDownload,
|
|
IconInfoCircle,
|
|
IconSearch,
|
|
} from '@tabler/icons-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ModalFooter } from '@ema-platform/ui';
|
|
import {
|
|
useCreateDocumentRequirementMutation,
|
|
useGetLicenseTypesQuery,
|
|
useGetPersonalDocumentsQuery,
|
|
useLocalized,
|
|
type ApplicationKind,
|
|
type DocumentRequirement,
|
|
type LicenseType,
|
|
type PersonalDocumentGroup,
|
|
} from '@ema-platform/api';
|
|
import { useRequirementActions } from '../hooks/useRequirementActions';
|
|
import type { DraftRequirement } from './DocumentRequirementEditorDrawer';
|
|
|
|
const MIME_LABELS: Record<string, string> = {
|
|
'application/pdf': 'PDF',
|
|
'application/msword': 'DOC',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
|
|
'application/vnd.ms-excel': 'XLS',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
|
|
};
|
|
|
|
function shortMime(mime: string): string {
|
|
return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime;
|
|
}
|
|
|
|
interface ImportPersonalDocumentsModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
licenseType: LicenseType;
|
|
applicationKind: ApplicationKind;
|
|
existingKeys: string[];
|
|
currentMaxSortOrder: number;
|
|
onCustomizeAndAdd: (draft: Partial<DraftRequirement>) => void;
|
|
}
|
|
|
|
export function ImportPersonalDocumentsModal({
|
|
opened,
|
|
onClose,
|
|
licenseType,
|
|
applicationKind,
|
|
existingKeys,
|
|
currentMaxSortOrder,
|
|
onCustomizeAndAdd,
|
|
}: ImportPersonalDocumentsModalProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const localized = useLocalized();
|
|
const run = useRequirementActions();
|
|
|
|
const [search, setSearch] = useState('');
|
|
const [scopeFilter, setScopeFilter] = useState<'all' | 'scoped'>('all');
|
|
const [importMode, setImportMode] = useState<DocumentRequirement['mode']>('ALWAYS');
|
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
|
|
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
|
const { data: personalDocsData, isLoading, isError, refetch } = useGetPersonalDocumentsQuery({
|
|
take: 100,
|
|
locale: i18n.language === 'am' ? 'am' : 'en',
|
|
});
|
|
|
|
const [createRequirement, { isLoading: isCreating }] = useCreateDocumentRequirementMutation();
|
|
|
|
const groups = useMemo(() => {
|
|
return (personalDocsData?.items ?? []).map((group) => {
|
|
const scopeIds = group.rows
|
|
.map((r) => r.licenseTypeId)
|
|
.filter((id): id is string => id !== null);
|
|
const isGlobal = scopeIds.length === 0;
|
|
const appliesToCurrentType = isGlobal || scopeIds.includes(licenseType.id);
|
|
const isAlreadyAdded = existingKeys.includes(group.key);
|
|
|
|
return {
|
|
...group,
|
|
scopeIds,
|
|
isGlobal,
|
|
appliesToCurrentType,
|
|
isAlreadyAdded,
|
|
mainRow: group.rows[0],
|
|
};
|
|
});
|
|
}, [personalDocsData, licenseType.id, existingKeys]);
|
|
|
|
const filteredGroups = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return groups.filter((item) => {
|
|
if (scopeFilter === 'scoped' && !item.appliesToCurrentType) {
|
|
return false;
|
|
}
|
|
if (!q) return true;
|
|
const nameEn = item.mainRow.name.en?.toLowerCase() ?? '';
|
|
const nameAm = item.mainRow.name.am?.toLowerCase() ?? '';
|
|
const key = item.key.toLowerCase();
|
|
return nameEn.includes(q) || nameAm.includes(q) || key.includes(q);
|
|
});
|
|
}, [groups, search, scopeFilter]);
|
|
|
|
const selectableKeys = useMemo(
|
|
() => filteredGroups.filter((g) => !g.isAlreadyAdded).map((g) => g.key),
|
|
[filteredGroups],
|
|
);
|
|
|
|
const allSelected =
|
|
selectableKeys.length > 0 && selectableKeys.every((k) => selectedKeys.includes(k));
|
|
const someSelected =
|
|
selectableKeys.some((k) => selectedKeys.includes(k)) && !allSelected;
|
|
|
|
function toggleSelectAll() {
|
|
if (allSelected) {
|
|
setSelectedKeys((prev) => prev.filter((k) => !selectableKeys.includes(k)));
|
|
} else {
|
|
setSelectedKeys((prev) => Array.from(new Set([...prev, ...selectableKeys])));
|
|
}
|
|
}
|
|
|
|
function toggleSelectOne(key: string) {
|
|
setSelectedKeys((prev) =>
|
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
|
);
|
|
}
|
|
|
|
async function handleBatchImport() {
|
|
const itemsToImport = groups.filter(
|
|
(g) => selectedKeys.includes(g.key) && !g.isAlreadyAdded,
|
|
);
|
|
if (itemsToImport.length === 0) return;
|
|
|
|
const ok = await run(
|
|
() =>
|
|
Promise.all(
|
|
itemsToImport.map((item, index) =>
|
|
createRequirement({
|
|
key: item.key,
|
|
name: item.mainRow.name,
|
|
description: item.mainRow.description,
|
|
applicationKind,
|
|
mode: importMode,
|
|
allowedMimeTypes:
|
|
item.mainRow.allowedMimeTypes ?? ['application/pdf', 'image/jpeg', 'image/png'],
|
|
maxSizeMb: item.mainRow.maxSizeMb ?? 5,
|
|
requiresValidityDates: item.mainRow.requiresValidityDates ?? false,
|
|
allowMultiple: (item.mainRow.maxFiles ?? 1) !== 1,
|
|
isPersonal: false,
|
|
maxFiles: item.mainRow.maxFiles ?? null,
|
|
sortOrder: currentMaxSortOrder + index + 1,
|
|
licenseTypeId: licenseType.id,
|
|
}).unwrap(),
|
|
),
|
|
),
|
|
t('certReq.doc.importSuccess', 'Imported {{count}} personal document(s)', {
|
|
count: itemsToImport.length,
|
|
}),
|
|
);
|
|
|
|
if (ok) {
|
|
setSelectedKeys([]);
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
function handleCustomizeRow(item: (typeof groups)[number]) {
|
|
onCustomizeAndAdd({
|
|
key: item.key,
|
|
name: { ...item.mainRow.name },
|
|
description: item.mainRow.description ? { ...item.mainRow.description } : undefined,
|
|
applicationKind,
|
|
mode: importMode,
|
|
allowedMimeTypes: [...item.mainRow.allowedMimeTypes],
|
|
maxSizeMb: item.mainRow.maxSizeMb,
|
|
maxFiles: item.mainRow.maxFiles,
|
|
requiresValidityDates: item.mainRow.requiresValidityDates,
|
|
allowMultiple: (item.mainRow.maxFiles ?? 1) !== 1,
|
|
isPersonal: false,
|
|
sortOrder: currentMaxSortOrder + 1,
|
|
});
|
|
onClose();
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
title={
|
|
<div>
|
|
<Text fw={700} fz="lg">
|
|
{t('certReq.doc.importPersonal', 'Import from personal documents')}
|
|
</Text>
|
|
<Text fz="xs" c="dimmed">
|
|
{localized(licenseType.name)} · {applicationKind}
|
|
</Text>
|
|
</div>
|
|
}
|
|
size="lg"
|
|
padding="md"
|
|
>
|
|
<Stack gap="md">
|
|
<Alert
|
|
variant="light"
|
|
color="blue"
|
|
icon={<IconInfoCircle size={16} />}
|
|
>
|
|
<Text fz="xs">
|
|
{t(
|
|
'certReq.doc.importedHelper',
|
|
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
|
)}
|
|
</Text>
|
|
</Alert>
|
|
|
|
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
|
<TextInput
|
|
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
|
|
leftSection={<IconSearch size={14} />}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
size="xs"
|
|
style={{ flex: 1, minWidth: 200 }}
|
|
/>
|
|
|
|
<SegmentedControl
|
|
size="xs"
|
|
value={scopeFilter}
|
|
onChange={(v) => setScopeFilter(v as 'all' | 'scoped')}
|
|
data={[
|
|
{ value: 'all', label: t('certReq.personal.filterAny', 'All personal docs') },
|
|
{
|
|
value: 'scoped',
|
|
label: t('certReq.doc.scopeSelected', 'Relevant to licence'),
|
|
},
|
|
]}
|
|
/>
|
|
</Group>
|
|
|
|
<Group justify="space-between" align="center" gap="sm">
|
|
<Select
|
|
label={t('certReq.doc.importMode', 'Requirement mode for imported documents')}
|
|
size="xs"
|
|
w={240}
|
|
data={[
|
|
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
|
|
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
|
|
{
|
|
value: 'CONDITIONAL',
|
|
label: t('certReq.doc.modeConditional', 'Required when condition holds'),
|
|
},
|
|
]}
|
|
value={importMode}
|
|
onChange={(v) => v && setImportMode(v as DocumentRequirement['mode'])}
|
|
allowDeselect={false}
|
|
/>
|
|
|
|
<Text fz="xs" c="dimmed" pt="lg">
|
|
{t('certReq.personal.fileCount', '{{count}} document(s) available', {
|
|
count: selectableKeys.length,
|
|
})}
|
|
</Text>
|
|
</Group>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="xl">
|
|
<Loader size="sm" />
|
|
<Text fz="sm" c="dimmed">
|
|
{t('certReq.doc.loading', 'Loading personal documents…')}
|
|
</Text>
|
|
</Group>
|
|
) : isError ? (
|
|
<Alert color="red" icon={<IconAlertCircle size={16} />}>
|
|
{t('certReq.doc.loadFailed', 'Could not load personal documents')}
|
|
<Button size="xs" variant="subtle" color="red" ml="sm" onClick={() => refetch()}>
|
|
{t('landing.retry', 'Retry')}
|
|
</Button>
|
|
</Alert>
|
|
) : filteredGroups.length === 0 ? (
|
|
<Card withBorder radius="sm" p="lg" ta="center">
|
|
<Text fz="sm" c="dimmed">
|
|
{search
|
|
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
|
|
: t('certReq.doc.noPersonalDocs', 'No personal documents configured in Configuration yet.')}
|
|
</Text>
|
|
</Card>
|
|
) : (
|
|
<ScrollArea.Autosize mah={380} type="auto">
|
|
<Table highlightOnHover verticalSpacing="xs" fz="sm">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th w={40}>
|
|
<Checkbox
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={toggleSelectAll}
|
|
disabled={selectableKeys.length === 0}
|
|
/>
|
|
</Table.Th>
|
|
<Table.Th>{t('certReq.personal.columns.document', 'Document')}</Table.Th>
|
|
<Table.Th>{t('certReq.doc.allowedTypes', 'Format & Limits')}</Table.Th>
|
|
<Table.Th>{t('certReq.doc.scope', 'Scope')}</Table.Th>
|
|
<Table.Th ta="right">{t('certReq.personal.columns.actions', 'Actions')}</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{filteredGroups.map((item) => {
|
|
const isSelected = selectedKeys.includes(item.key);
|
|
return (
|
|
<Table.Tr
|
|
key={item.key}
|
|
bg={
|
|
item.isAlreadyAdded
|
|
? 'var(--mantine-color-gray-light)'
|
|
: isSelected
|
|
? 'var(--mantine-color-blue-light)'
|
|
: undefined
|
|
}
|
|
>
|
|
<Table.Td>
|
|
<Checkbox
|
|
checked={isSelected || item.isAlreadyAdded}
|
|
disabled={item.isAlreadyAdded}
|
|
onChange={() => toggleSelectOne(item.key)}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<div>
|
|
<Group gap={6}>
|
|
<Text fz="sm" fw={600}>
|
|
{localized(item.mainRow.name) || item.key}
|
|
</Text>
|
|
{item.isAlreadyAdded && (
|
|
<Badge size="xs" color="gray" variant="light">
|
|
{t('certReq.doc.alreadyAdded', 'Already added')}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<Text fz="xs" c="dimmed">
|
|
{item.key}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text fz="xs">
|
|
{(item.mainRow.allowedMimeTypes ?? []).slice(0, 3).map(shortMime).join(', ')}
|
|
{(item.mainRow.allowedMimeTypes ?? []).length > 3 && '...'}
|
|
</Text>
|
|
<Text fz="xs" c="dimmed">
|
|
{item.mainRow.maxSizeMb} MB ·{' '}
|
|
{item.mainRow.maxFiles === null
|
|
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
|
: `${item.mainRow.maxFiles} file(s)`}
|
|
{item.mainRow.requiresValidityDates && ' · Validity'}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{item.isGlobal ? (
|
|
<Badge size="xs" variant="filled" color="gray">
|
|
{t('certReq.doc.scopeAll', 'All licences')}
|
|
</Badge>
|
|
) : (
|
|
<Badge size="xs" variant="light" color="blue">
|
|
{t('certReq.doc.scopeSelected', 'Selected')}
|
|
</Badge>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td ta="right">
|
|
<Tooltip label={t('certReq.doc.customizeAndAdd', 'Customize & add')}>
|
|
<ActionIcon
|
|
variant="light"
|
|
color="blue"
|
|
size="sm"
|
|
onClick={() => handleCustomizeRow(item)}
|
|
>
|
|
<IconAdjustments size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</ScrollArea.Autosize>
|
|
)}
|
|
|
|
<ModalFooter>
|
|
<Button variant="default" onClick={onClose}>
|
|
{t('certReq.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button
|
|
color="teal"
|
|
leftSection={<IconDownload size={15} />}
|
|
disabled={selectedKeys.length === 0}
|
|
loading={isCreating}
|
|
onClick={handleBatchImport}
|
|
>
|
|
{t('certReq.doc.importSelected', 'Import selected ({{count}})', {
|
|
count: selectedKeys.length,
|
|
})}
|
|
</Button>
|
|
</ModalFooter>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|