mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 02:30:57 +00:00
feat: refactor personal document management to use server-side pagination, grouping, and filtering
This commit is contained in:
@@ -4,24 +4,31 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import {
|
||||
AdvancedTable,
|
||||
ModalFooter,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
useCreateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useLocalized,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
type DocumentRequirement,
|
||||
type PersonalDocumentGroup as PersonalDocumentGroupDto,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import {
|
||||
@@ -31,17 +38,33 @@ import {
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
/** Filter value for "the documents every licence asks for". */
|
||||
const GLOBAL_ONLY = 'GLOBAL';
|
||||
|
||||
/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* One personal document as an administrator thinks of it.
|
||||
* A document as the table renders it: what the server sent, plus the row id
|
||||
* `AdvancedTable` keys on and the scope read off its rows.
|
||||
*
|
||||
* The table stores a row per licence type, so "sea service book, for CoC and
|
||||
* endorsements" is two rows sharing a key. They are one document here — and
|
||||
* one slot in the applicant's vault — so the screen groups by key and the
|
||||
* scope is the set of licence types those rows name.
|
||||
* The grouping itself belongs to the server — a page of rows would split a
|
||||
* document configured for three licence types across two pages and misreport
|
||||
* the scope of both halves.
|
||||
*/
|
||||
interface PersonalDocumentGroup {
|
||||
key: string;
|
||||
rows: DocumentRequirement[];
|
||||
interface PersonalDocumentGroup extends PersonalDocumentGroupDto {
|
||||
/** The key doubles as the row id; one group is one document. */
|
||||
id: string;
|
||||
/** Empty when the document applies to every licence. */
|
||||
scope: PersonalScope;
|
||||
}
|
||||
@@ -56,11 +79,10 @@ interface PersonalDocumentGroup {
|
||||
* book is worth asking a seafarer for and pointless for a freight forwarder.
|
||||
*/
|
||||
export function PersonalDocumentsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const run = useRequirementActions();
|
||||
|
||||
const { data } = useGetDocumentRequirementsQuery();
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
|
||||
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
|
||||
@@ -68,31 +90,148 @@ export function PersonalDocumentsCard() {
|
||||
|
||||
const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PersonalDocumentGroup | null>(null);
|
||||
/** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */
|
||||
const [licenseTypeFilter, setLicenseTypeFilter] = useState<string | null>(null);
|
||||
|
||||
const groups = useMemo<PersonalDocumentGroup[]>(() => {
|
||||
const byKey = new Map<string, DocumentRequirement[]>();
|
||||
for (const row of data?.items ?? []) {
|
||||
if (!row.isPersonal) continue;
|
||||
byKey.set(row.key, [...(byKey.get(row.key) ?? []), row]);
|
||||
}
|
||||
return [...byKey.entries()]
|
||||
.map(([key, rows]) => ({
|
||||
key,
|
||||
rows,
|
||||
const { q, setQ, pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
// Every facet goes to the server: it filters and searches in SQL, groups the
|
||||
// rows into documents, then pages the documents.
|
||||
const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({
|
||||
search: q.trim() || undefined,
|
||||
licenseTypeId:
|
||||
licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY
|
||||
? licenseTypeFilter
|
||||
: undefined,
|
||||
globalOnly: licenseTypeFilter === GLOBAL_ONLY,
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
locale: i18n.language === 'am' ? 'am' : 'en',
|
||||
});
|
||||
|
||||
const groups = useMemo<PersonalDocumentGroup[]>(
|
||||
() =>
|
||||
(data?.items ?? []).map((group) => ({
|
||||
...group,
|
||||
id: group.key,
|
||||
// A single row with no licence type means "every licence"; the two
|
||||
// never coexist, because the editor writes one shape or the other.
|
||||
scope: rows
|
||||
scope: group.rows
|
||||
.map((r) => r.licenseTypeId)
|
||||
.filter((id): id is string => id !== null),
|
||||
}))
|
||||
.sort((a, b) => a.rows[0].sortOrder - b.rows[0].sortOrder);
|
||||
}, [data]);
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
/** Filters are the server's business now; an empty page is its answer. */
|
||||
const isFiltered = q.trim() !== '' || licenseTypeFilter !== null;
|
||||
|
||||
const typeName = (id: string) => {
|
||||
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
|
||||
return found ? localized(found.name) || found.key : id;
|
||||
};
|
||||
|
||||
const columns = useMemo<AdvancedColumn<PersonalDocumentGroup>[]>(
|
||||
() => [
|
||||
{
|
||||
header: t('certReq.personal.columns.document', 'Document'),
|
||||
label: t('certReq.personal.columns.document', 'Document'),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{localized(row.original.rows[0].name) || row.original.key}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{row.original.key}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.scope', 'Applies to'),
|
||||
label: t('certReq.doc.scope', 'Applies to'),
|
||||
cell: ({ row }) =>
|
||||
row.original.scope.length === 0 ? (
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{t('certReq.doc.scopeAll', 'All licences')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
{row.original.scope.map((id) => (
|
||||
<Badge key={id} size="sm" variant="outline" color="grape">
|
||||
{typeName(id)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.maxFiles', 'Files accepted'),
|
||||
label: t('certReq.doc.maxFiles', 'Files accepted'),
|
||||
align: 'center',
|
||||
cell: ({ row }) =>
|
||||
row.original.rows[0].maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: row.original.rows[0].maxFiles,
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.allowedTypes', 'Allowed file types'),
|
||||
label: t('certReq.doc.allowedTypes', 'Allowed file types'),
|
||||
cell: ({ row }) => {
|
||||
const types = row.original.rows[0].allowedMimeTypes ?? [];
|
||||
return (
|
||||
// Twenty-odd mime types would own the row; the full list is one
|
||||
// hover away instead.
|
||||
<Tooltip label={types.join(', ')} multiline w={280} disabled={types.length <= 3}>
|
||||
<Text fz="xs">
|
||||
{types.slice(0, 3).map(shortMime).join(', ')}
|
||||
{types.length > 3
|
||||
? t('certReq.personal.moreTypes', ' +{{count}} more', {
|
||||
count: types.length - 3,
|
||||
})
|
||||
: ''}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('certReq.doc.maxSize', 'Max file size (MB)'),
|
||||
label: t('certReq.doc.maxSize', 'Max file size (MB)'),
|
||||
align: 'center',
|
||||
cell: ({ row }) => `${row.original.rows[0].maxSizeMb} MB`,
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('certReq.personal.columns.actions', 'Actions'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditing({ group: row.original })}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
// `typeName` closes over the licence-type list, which `localized` also reads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, localized, licenseTypes],
|
||||
);
|
||||
|
||||
/**
|
||||
* Saves the group as the set of rows it now means.
|
||||
*
|
||||
@@ -137,89 +276,71 @@ export function PersonalDocumentsCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb={4} wrap="nowrap">
|
||||
<Title order={5}>{t('certReq.personal.title', 'Personal documents')}</Title>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Title order={5}>{t('certReq.personal.title', 'Personal documents')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t(
|
||||
'certReq.personal.subtitle',
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditing({ group: null })}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t('certReq.personal.add', 'Add personal document')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed" mb="sm">
|
||||
{t(
|
||||
'certReq.personal.subtitle',
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
{t('certReq.personal.empty', 'No personal documents configured yet.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{groups.map((group) => {
|
||||
const first = group.rows[0];
|
||||
return (
|
||||
<Card
|
||||
key={group.key}
|
||||
withBorder
|
||||
radius="sm"
|
||||
p="xs"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap={6}>
|
||||
<Text fz="sm" fw={600} truncate>
|
||||
{localized(first.name) || group.key}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{first.maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: t('certReq.personal.fileCount', '{{count}} file', {
|
||||
count: first.maxFiles,
|
||||
})}
|
||||
</Badge>
|
||||
{group.scope.length === 0 ? (
|
||||
<Badge size="xs" variant="outline" color="blue">
|
||||
{t('certReq.doc.scopeAll', 'All licences')}
|
||||
</Badge>
|
||||
) : (
|
||||
group.scope.map((id) => (
|
||||
<Badge key={id} size="xs" variant="outline" color="grape">
|
||||
{typeName(id)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
key: {group.key} · {first.maxSizeMb}MB ·{' '}
|
||||
{first.allowedMimeTypes.join(', ')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditing({ group })}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(group)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<AdvancedTable
|
||||
tableName={t('certReq.personal.title', 'Personal documents')}
|
||||
columns={columns}
|
||||
data={groups}
|
||||
itemCount={data?.total ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setQ}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={
|
||||
isFiltered
|
||||
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
|
||||
: t('certReq.personal.empty', 'No personal documents configured yet.')
|
||||
}
|
||||
toolbar={
|
||||
<Select
|
||||
placeholder={t('certReq.personal.filterAny', 'Any licence type')}
|
||||
data={[
|
||||
{
|
||||
value: GLOBAL_ONLY,
|
||||
label: t('certReq.personal.filterGlobal', 'All-licence documents only'),
|
||||
},
|
||||
...(licenseTypes?.items ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key })),
|
||||
]}
|
||||
value={licenseTypeFilter}
|
||||
onChange={(value) => {
|
||||
setLicenseTypeFilter(value);
|
||||
// A narrower list can be shorter than the page you were on.
|
||||
setPageIndex(0);
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
size="sm"
|
||||
w={240}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editing !== null}
|
||||
@@ -265,6 +386,6 @@ export function PersonalDocumentsCard() {
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1374,6 +1374,16 @@ export const am: Translations = {
|
||||
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
|
||||
add: "የግል ሰነድ ጨምር",
|
||||
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
|
||||
searchPlaceholder: "በስም ወይም በቁልፍ ይፈልጉ",
|
||||
moreTypes: " +{{count}} ተጨማሪ",
|
||||
columns: {
|
||||
document: "ሰነድ",
|
||||
actions: "ድርጊቶች",
|
||||
},
|
||||
filterAny: "ማንኛውም የፈቃድ አይነት",
|
||||
filterGlobal: "ለሁሉም ፈቃዶች የሚሆኑ ብቻ",
|
||||
noMatch: "በእነዚህ ማጣሪያዎች የሚመጣጠን የግል ሰነድ የለም።",
|
||||
clearFilters: "ማጣሪያዎችን አጽዳ",
|
||||
fileCount_one: "{{count}} ፋይል",
|
||||
fileCount_other: "{{count}} ፋይሎች",
|
||||
deleteWarning:
|
||||
|
||||
@@ -1380,6 +1380,16 @@ export const en = {
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
add: 'Add personal document',
|
||||
empty: 'No personal documents configured yet.',
|
||||
searchPlaceholder: 'Search by name or key',
|
||||
moreTypes: ' +{{count}} more',
|
||||
columns: {
|
||||
document: 'Document',
|
||||
actions: 'Actions',
|
||||
},
|
||||
filterAny: 'Any licence type',
|
||||
filterGlobal: 'All-licence documents only',
|
||||
noMatch: 'No personal document matches those filters.',
|
||||
clearFilters: 'Clear filters',
|
||||
fileCount_one: '{{count}} file',
|
||||
fileCount_other: '{{count}} files',
|
||||
deleteWarning:
|
||||
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
TemplateVariable,
|
||||
PersonalDocumentFilter,
|
||||
PersonalDocumentGroup,
|
||||
} from './licensing.types';
|
||||
|
||||
/**
|
||||
@@ -63,6 +65,16 @@ function serialiseQueueFilter(
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Sends only the facets that are set; `search=` would match nothing. */
|
||||
function dropEmpty(filter: object): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value === undefined || value === null || value === '' || value === false) continue;
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const TAGS = [
|
||||
'LicenseType',
|
||||
'OperatorType',
|
||||
@@ -245,6 +257,25 @@ export const licensingApi = baseApi
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Personal document slots, grouped by key and paged by the server.
|
||||
*
|
||||
* Its own endpoint rather than filtering `getDocumentRequirements` in the
|
||||
* browser: one document can be configured against several licence types,
|
||||
* so a page of rows would split a document in half and misreport its
|
||||
* scope. The server groups first, then pages.
|
||||
*/
|
||||
getPersonalDocuments: builder.query<
|
||||
Paginated<PersonalDocumentGroup>,
|
||||
PersonalDocumentFilter | void
|
||||
>({
|
||||
query: (filter) => ({
|
||||
url: '/document-requirements/personal',
|
||||
params: dropEmpty(filter ?? {}),
|
||||
}),
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
createDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
// No `licenseTypeId` means a personal document, required for every
|
||||
@@ -1109,6 +1140,7 @@ export const {
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
|
||||
@@ -761,6 +761,31 @@ export interface IssuedLicense {
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One personal document as the backoffice manages it: every configured row
|
||||
* sharing a key, which is one slot in the applicant's vault. Several rows mean
|
||||
* the document is scoped to several licence types.
|
||||
*/
|
||||
export interface PersonalDocumentGroup {
|
||||
key: string;
|
||||
rows: DocumentRequirement[];
|
||||
}
|
||||
|
||||
export interface PersonalDocumentFilter {
|
||||
/** Matches the key and the name in either locale. */
|
||||
search?: string;
|
||||
/** A licence type also matches the documents every licence asks for. */
|
||||
licenseTypeId?: string;
|
||||
/** Narrows to the documents configured against no licence type at all. */
|
||||
globalOnly?: boolean;
|
||||
sortBy?: 'sortOrder' | 'key' | 'name';
|
||||
sortDir?: 'ASC' | 'DESC';
|
||||
take?: number;
|
||||
skip?: number;
|
||||
/** Which locale `sortBy: "name"` sorts on. */
|
||||
locale?: 'en' | 'am';
|
||||
}
|
||||
|
||||
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
|
||||
export interface EligibleExam {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user