diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx index 1ca866e71..220729868 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/PersonalDocumentsCard.tsx @@ -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; +/** 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 = { + '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(null); + /** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */ + const [licenseTypeFilter, setLicenseTypeFilter] = useState(null); - const groups = useMemo(() => { - const byKey = new Map(); - 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( + () => + (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[]>( + () => [ + { + header: t('certReq.personal.columns.document', 'Document'), + label: t('certReq.personal.columns.document', 'Document'), + cell: ({ row }) => ( +
+ + {localized(row.original.rows[0].name) || row.original.key} + + + {row.original.key} + +
+ ), + }, + { + header: t('certReq.doc.scope', 'Applies to'), + label: t('certReq.doc.scope', 'Applies to'), + cell: ({ row }) => + row.original.scope.length === 0 ? ( + + {t('certReq.doc.scopeAll', 'All licences')} + + ) : ( + + {row.original.scope.map((id) => ( + + {typeName(id)} + + ))} + + ), + }, + { + 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. + + + {types.slice(0, 3).map(shortMime).join(', ')} + {types.length > 3 + ? t('certReq.personal.moreTypes', ' +{{count}} more', { + count: types.length - 3, + }) + : ''} + + + ); + }, + }, + { + 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 }) => ( + + setEditing({ group: row.original })} + > + + + setDeleteTarget(row.original)} + > + + + + ), + }, + ], + // `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 ( - - - {t('certReq.personal.title', 'Personal documents')} + + +
+ {t('certReq.personal.title', 'Personal documents')} + + {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.', + )} + +
- - {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.', - )} - - {groups.length === 0 ? ( - - {t('certReq.personal.empty', 'No personal documents configured yet.')} - - ) : ( - - {groups.map((group) => { - const first = group.rows[0]; - return ( - - -
- - - {localized(first.name) || group.key} - - - {first.maxFiles === null - ? t('certReq.doc.maxFilesUnlimited', 'No limit') - : t('certReq.personal.fileCount', '{{count}} file', { - count: first.maxFiles, - })} - - {group.scope.length === 0 ? ( - - {t('certReq.doc.scopeAll', 'All licences')} - - ) : ( - group.scope.map((id) => ( - - {typeName(id)} - - )) - )} - - - key: {group.key} · {first.maxSizeMb}MB ·{' '} - {first.allowedMimeTypes.join(', ')} - -
- - setEditing({ group })} - > - - - setDeleteTarget(group)}> - - - -
-
- ); - })} -
- )} + 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} + /> + } + />
-
+ ); } diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 08524d17d..6761af0d8 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 77c11329f..829496b0b 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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: diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 0591b1583..be10a4409 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -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 { + const params: Record = {}; + 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, + 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, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 384038296..0acb0c0ba 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -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;