mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-02 02:23:27 +00:00
feat: add support for global personal document requirements in applicant vaults
This commit is contained in:
@@ -25,21 +25,32 @@ const MIME_OPTIONS = [
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
function emptyDraft(applicationKind: ApplicationKind): DraftRequirement {
|
||||
function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement {
|
||||
return {
|
||||
key: '',
|
||||
name: { en: '', am: '' },
|
||||
applicationKind,
|
||||
mode: 'ALWAYS',
|
||||
// A personal document is never demanded by one application, so "always
|
||||
// required" would be a promise nothing here can keep.
|
||||
mode: personal ? 'OPTIONAL' : 'ALWAYS',
|
||||
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
|
||||
maxSizeMb: 5,
|
||||
requiresValidityDates: false,
|
||||
allowMultiple: false,
|
||||
maxFiles: personal ? 1 : null,
|
||||
sortOrder: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds/edits one document requirement slot for a licence type + application kind. */
|
||||
/**
|
||||
* Adds/edits one document requirement slot.
|
||||
*
|
||||
* Two shapes of the same row: a slot on one licence type's application form,
|
||||
* and — with `personal` — a document every applicant keeps in their own vault
|
||||
* whatever they apply for. The vault has no application to condition on and no
|
||||
* renewal of its own, so those fields are hidden rather than left to mean
|
||||
* nothing.
|
||||
*/
|
||||
export function DocumentRequirementEditorDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
@@ -49,6 +60,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
palette,
|
||||
conditionTargets,
|
||||
saving,
|
||||
personal = false,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -59,9 +71,13 @@ export function DocumentRequirementEditorDrawer({
|
||||
palette: FormSchemaPalette | undefined;
|
||||
conditionTargets: ConditionTarget[];
|
||||
saving: boolean;
|
||||
/** Editing a personal document — one that applies to every licence. */
|
||||
personal?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<DraftRequirement>(emptyDraft(defaultApplicationKind));
|
||||
const [draft, setDraft] = useState<DraftRequirement>(
|
||||
emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const isNew = !requirement;
|
||||
|
||||
@@ -80,13 +96,14 @@ export function DocumentRequirementEditorDrawer({
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
}
|
||||
: emptyDraft(defaultApplicationKind),
|
||||
: emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
setKeyError(null);
|
||||
}
|
||||
}, [opened, requirement, defaultApplicationKind]);
|
||||
}, [opened, requirement, defaultApplicationKind, personal]);
|
||||
|
||||
function save() {
|
||||
if (!draft.key.trim()) {
|
||||
@@ -111,6 +128,9 @@ export function DocumentRequirementEditorDrawer({
|
||||
...draft,
|
||||
key: draft.key.trim(),
|
||||
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
|
||||
// `allowMultiple` predates `maxFiles` and nothing reads it any more; kept
|
||||
// in step so the two columns never contradict each other.
|
||||
allowMultiple: draft.maxFiles !== 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,32 +167,36 @@ export function DocumentRequirementEditorDrawer({
|
||||
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('certReq.doc.applicationKind', 'Application kind')}
|
||||
data={[
|
||||
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
|
||||
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
|
||||
]}
|
||||
value={draft.applicationKind}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
|
||||
allowDeselect={false}
|
||||
disabled={!isNew}
|
||||
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
|
||||
/>
|
||||
{!personal && (
|
||||
<Select
|
||||
label={t('certReq.doc.applicationKind', 'Application kind')}
|
||||
data={[
|
||||
{ value: 'NEW', label: t('certReq.doc.kindNew', 'New application') },
|
||||
{ value: 'RENEWAL', label: t('certReq.doc.kindRenewal', 'Renewal') },
|
||||
]}
|
||||
value={draft.applicationKind}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))}
|
||||
allowDeselect={false}
|
||||
disabled={!isNew}
|
||||
description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t('certReq.doc.mode', 'Mode')}
|
||||
data={[
|
||||
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
|
||||
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
|
||||
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
|
||||
]}
|
||||
value={draft.mode}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{!personal && (
|
||||
<Select
|
||||
label={t('certReq.doc.mode', 'Mode')}
|
||||
data={[
|
||||
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
|
||||
{ value: 'CONDITIONAL', label: t('certReq.doc.modeConditional', 'Required when condition holds') },
|
||||
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
|
||||
]}
|
||||
value={draft.mode}
|
||||
onChange={(v) => v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{draft.mode === 'CONDITIONAL' && (
|
||||
{!personal && draft.mode === 'CONDITIONAL' && (
|
||||
<>
|
||||
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
|
||||
<ConditionBuilder
|
||||
@@ -199,16 +223,26 @@ export function DocumentRequirementEditorDrawer({
|
||||
onChange={(v) => setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
|
||||
checked={draft.requiresValidityDates}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
|
||||
/>
|
||||
{!personal && (
|
||||
<Checkbox
|
||||
label={t('certReq.doc.requiresValidity', 'Requires validity dates')}
|
||||
checked={draft.requiresValidityDates}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={t('certReq.doc.allowMultiple', 'Allow multiple uploads')}
|
||||
checked={draft.allowMultiple}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))}
|
||||
<NumberInput
|
||||
label={t('certReq.doc.maxFiles', 'Files accepted')}
|
||||
description={t(
|
||||
'certReq.doc.maxFilesHelp',
|
||||
'Leave empty for no limit. Use 2 for a document with a front and a back.',
|
||||
)}
|
||||
placeholder={t('certReq.doc.maxFilesUnlimited', 'No limit')}
|
||||
min={1}
|
||||
value={draft.maxFiles ?? ''}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, maxFiles: typeof v === 'number' ? v : null }))
|
||||
}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import {
|
||||
useCreateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useLocalized,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
|
||||
/**
|
||||
* Documents every applicant keeps, whatever they apply for.
|
||||
*
|
||||
* These are the same `document_requirements` rows as the per-licence-type tab
|
||||
* below, minus the licence type: a row with none is not a slot on one
|
||||
* application form but a slot in the applicant's own document vault, which
|
||||
* the portal shows under My Documents. Kept out of the licence-type tab
|
||||
* deliberately — a document that belongs to everybody reads wrong filed under
|
||||
* whichever type happened to be selected.
|
||||
*/
|
||||
export function PersonalDocumentsCard() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const run = useRequirementActions();
|
||||
|
||||
const { data } = useGetDocumentRequirementsQuery();
|
||||
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
|
||||
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
|
||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||
|
||||
const [editing, setEditing] = useState<{ requirement: DocumentRequirement | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(data?.items ?? [])
|
||||
.filter((r) => r.licenseTypeId === null)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[data],
|
||||
);
|
||||
|
||||
async function handleSave(draft: Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>) {
|
||||
const ok = await run(
|
||||
() =>
|
||||
editing?.requirement
|
||||
? updateRequirement({ id: editing.requirement.id, ...draft }).unwrap()
|
||||
: // No licenceTypeId at all: that absence is what makes it personal.
|
||||
createRequirement(draft).unwrap(),
|
||||
editing?.requirement
|
||||
? t('certReq.doc.updated', 'Document requirement updated')
|
||||
: t('certReq.doc.created', 'Document requirement added'),
|
||||
);
|
||||
if (ok) setEditing(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);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb={4} wrap="nowrap">
|
||||
<Title order={5}>{t('certReq.personal.title', 'Documents required for every licence')}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditing({ requirement: null })}
|
||||
>
|
||||
{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.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
{t('certReq.personal.empty', 'No personal documents configured yet.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{rows.map((req) => (
|
||||
<Card key={req.id} 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(req.name) || req.key}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{req.maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: t('certReq.personal.fileCount', '{{count}} file', { count: req.maxFiles })}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditing({ requirement: req })}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(req)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editing !== null}
|
||||
onClose={() => setEditing(null)}
|
||||
requirement={editing?.requirement ?? null}
|
||||
defaultApplicationKind="NEW"
|
||||
onSave={handleSave}
|
||||
palette={undefined}
|
||||
conditionTargets={[]}
|
||||
saving={creating || updating}
|
||||
personal
|
||||
/>
|
||||
|
||||
<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.personal.deleteWarning',
|
||||
'The slot disappears from every applicant’s My Documents. Files already uploaded are kept, but nobody can reach them.',
|
||||
)}
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api';
|
||||
import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab';
|
||||
import { FormSchemaTab } from '../components/FormSchemaTab';
|
||||
import { PersonalDocumentsCard } from '../components/PersonalDocumentsCard';
|
||||
|
||||
/**
|
||||
* Where an administrator configures what an applicant must fill in and
|
||||
@@ -58,6 +59,11 @@ export function CertificateRequirementsPage() {
|
||||
<PageLoader label={t('certReq.loading', 'Loading licence types…')} height={300} />
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{/* Above the licence-type picker on purpose: these documents
|
||||
belong to no single type, so filing them under whichever one
|
||||
happens to be selected would misread. */}
|
||||
<PersonalDocumentsCard />
|
||||
|
||||
<Select
|
||||
label={t('certReq.licenseType', 'Licence type')}
|
||||
placeholder={t('certReq.selectLicenseType', 'Select a licence type')}
|
||||
|
||||
@@ -1353,9 +1353,23 @@ export const am: Translations = {
|
||||
requiresValidity: "የቀን ገደብ ያስፈልጋል",
|
||||
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
|
||||
multiple: "ብዙ",
|
||||
maxFiles: "የሚፈቀዱ ፋይሎች",
|
||||
maxFilesHelp: "ገደብ ከሌለ ባዶ ይተዉት። ፊትና ጀርባ ላለው ሰነድ 2 ይጠቀሙ።",
|
||||
maxFilesUnlimited: "ገደብ የለም",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
when: "መቼ",
|
||||
},
|
||||
personal: {
|
||||
title: "ለሁሉም ፈቃዶች የሚያስፈልጉ ሰነዶች",
|
||||
subtitle:
|
||||
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች።",
|
||||
add: "የግል ሰነድ ጨምር",
|
||||
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
|
||||
fileCount_one: "{{count}} ፋይል",
|
||||
fileCount_other: "{{count}} ፋይሎች",
|
||||
deleteWarning:
|
||||
"ማስገቢያው ከሁሉም አመልካቾች \u2018ሰነዶቼ\u2019 ውስጥ ይጠፋል። ቀደም ብለው የተሰቀሉ ፋይሎች ይቀመጣሉ፣ ነገር ግን ማንም ሊደርስባቸው አይችልም።",
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
|
||||
@@ -1358,9 +1358,23 @@ export const en = {
|
||||
requiresValidity: 'Requires validity dates',
|
||||
allowMultiple: 'Allow multiple uploads',
|
||||
multiple: 'multiple',
|
||||
maxFiles: 'Files accepted',
|
||||
maxFilesHelp: 'Leave empty for no limit. Use 2 for a document with a front and a back.',
|
||||
maxFilesUnlimited: 'No limit',
|
||||
sortOrder: 'Sort order',
|
||||
when: 'when',
|
||||
},
|
||||
personal: {
|
||||
title: 'Documents required for every licence',
|
||||
subtitle:
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application.',
|
||||
add: 'Add personal document',
|
||||
empty: 'No personal documents configured yet.',
|
||||
fileCount_one: '{{count}} file',
|
||||
fileCount_other: '{{count}} files',
|
||||
deleteWarning:
|
||||
'The slot disappears from every applicant\u2019s My Documents. Files already uploaded are kept, but nobody can reach them.',
|
||||
},
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconPaperclip,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useDeletePersonalDocumentFileMutation,
|
||||
useGetMyPersonalDocumentsQuery,
|
||||
useReplacePersonalDocumentFileMutation,
|
||||
useUploadPersonalDocumentMutation,
|
||||
type AttachmentFile,
|
||||
type PersonalDocumentSlot,
|
||||
} from '@ema-platform/api';
|
||||
import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* A refused upload comes back as a structured payload — which slot, what was
|
||||
* allowed, how big — so the applicant reads the real reason rather than
|
||||
* "upload failed".
|
||||
*/
|
||||
function errorBody(err: unknown): { message?: string } & Record<string, unknown> {
|
||||
const payload = (err as { data?: { message?: unknown } })?.data?.message;
|
||||
return typeof payload === 'object' && payload !== null
|
||||
? (payload as { message?: string } & Record<string, unknown>)
|
||||
: { message: typeof payload === 'string' ? payload : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant's own document vault.
|
||||
*
|
||||
* Slots are configuration, not code: the backoffice decides which documents
|
||||
* everyone keeps and how many files each holds, so labels, accepted types and
|
||||
* limits all arrive with the data. The upload button knows it is full for the
|
||||
* same reason the server refuses a third file.
|
||||
*/
|
||||
export function PersonalDocumentSlots({
|
||||
onPreview,
|
||||
}: {
|
||||
onPreview: (preview: { url: string; title: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const { data, isLoading } = useGetMyPersonalDocumentsQuery();
|
||||
const [uploadDocument] = useUploadPersonalDocumentMutation();
|
||||
const [replaceFile] = useReplacePersonalDocumentFileMutation();
|
||||
const [deleteFile] = useDeletePersonalDocumentFileMutation();
|
||||
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AttachmentFile | null>(null);
|
||||
// Mantine's FileButton clears its input through a ref object, and there is
|
||||
// one input per slot and per file, so the objects are kept by key.
|
||||
const resetRefs = useRef<Record<string, { current: (() => void) | null }>>({});
|
||||
function resetRef(key: string) {
|
||||
resetRefs.current[key] ??= { current: null };
|
||||
return resetRefs.current[key] as { current: () => void };
|
||||
}
|
||||
function clearInput(key: string) {
|
||||
resetRefs.current[key]?.current?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checked here as well as on the server so the common mistakes — a PDF where
|
||||
* a photograph belongs, a 12 MB scan — never cost a round trip.
|
||||
*/
|
||||
function rejectFile(slot: PersonalDocumentSlot, file: File): string | null {
|
||||
if (slot.allowedMimeTypes.length && !slot.allowedMimeTypes.includes(file.type)) {
|
||||
return t('documents.personal.errors.unsupported_document_type', {
|
||||
allowed: slot.allowedMimeTypes.join(', '),
|
||||
});
|
||||
}
|
||||
if (file.size > slot.maxSizeMb * 1024 * 1024) {
|
||||
return t('documents.personal.errors.document_too_large', {
|
||||
maxBytes: slot.maxSizeMb * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function run(busyKey: string, action: () => Promise<unknown>) {
|
||||
setBusy(busyKey);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
const body = errorBody(err);
|
||||
setError(
|
||||
t(`documents.personal.errors.${body.message ?? 'unknown'}`, {
|
||||
...body,
|
||||
defaultValue: t('documents.personal.errors.unknown'),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
clearInput(busyKey);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpload(slot: PersonalDocumentSlot, file: File | null) {
|
||||
if (!file) return;
|
||||
const rejected = rejectFile(slot, file);
|
||||
if (rejected) {
|
||||
setError(rejected);
|
||||
clearInput(slot.key);
|
||||
return;
|
||||
}
|
||||
return run(slot.key, () =>
|
||||
uploadDocument({ documentKey: slot.key, file }).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
function handleReplace(slot: PersonalDocumentSlot, fileId: string, file: File | null) {
|
||||
if (!file) return;
|
||||
const rejected = rejectFile(slot, file);
|
||||
if (rejected) {
|
||||
setError(rejected);
|
||||
clearInput(fileId);
|
||||
return;
|
||||
}
|
||||
return run(fileId, () => replaceFile({ fileId, file }).unwrap());
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
await run(deleteTarget.id, () => deleteFile(deleteTarget.id).unwrap());
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
|
||||
if (isLoading) return <Loader size="sm" type="oval" />;
|
||||
|
||||
const slots = data?.slots ?? [];
|
||||
if (slots.length === 0) {
|
||||
return (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.personal.empty')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.personal.description')}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} onClose={() => setError(null)} withCloseButton>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{slots.map((slot) => {
|
||||
const full = slot.maxFiles !== null && slot.files.length >= slot.maxFiles;
|
||||
return (
|
||||
<Card
|
||||
key={slot.key}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{
|
||||
borderStyle: slot.files.length ? 'solid' : 'dashed',
|
||||
borderColor: slot.files.length ? 'var(--mantine-color-teal-4)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{localized(slot.name)}
|
||||
</Text>
|
||||
{slot.description && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{localized(slot.description)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={slot.files.length ? 'teal' : 'gray'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{slot.maxFiles === null
|
||||
? t('documents.personal.fileCountUnlimited', { count: slot.files.length })
|
||||
: t('documents.personal.fileCount', {
|
||||
count: slot.files.length,
|
||||
max: slot.maxFiles,
|
||||
})}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
{slot.files.length === 0 ? (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('documents.files.none')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{slot.files.map((file) => (
|
||||
<Group key={file.id} gap={6} wrap="nowrap">
|
||||
<IconPaperclip size={14} />
|
||||
<Text
|
||||
fz="xs"
|
||||
style={{ flex: 1, cursor: file.url ? 'pointer' : undefined }}
|
||||
c={file.url ? 'blue' : undefined}
|
||||
truncate
|
||||
onClick={() =>
|
||||
file.url && onPreview({ url: file.url, title: file.originalName })
|
||||
}
|
||||
>
|
||||
{file.originalName}
|
||||
</Text>
|
||||
<RequirePermission
|
||||
anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]}
|
||||
hideOnly
|
||||
>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<FileButton
|
||||
resetRef={resetRef(file.id)}
|
||||
onChange={(picked) => handleReplace(slot, file.id, picked)}
|
||||
accept={slot.allowedMimeTypes.join(',')}
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip label={t('licensing.documents.replace')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
loading={busy === file.id}
|
||||
{...props}
|
||||
>
|
||||
<IconRefresh size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
<Tooltip label={t('documents.personal.delete')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
|
||||
<Tooltip label={t('documents.personal.slotFull')} disabled={!full}>
|
||||
<div>
|
||||
<FileButton
|
||||
resetRef={resetRef(slot.key)}
|
||||
onChange={(picked) => handleUpload(slot, picked)}
|
||||
accept={slot.allowedMimeTypes.join(',')}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant="light"
|
||||
fullWidth
|
||||
leftSection={<IconUpload size={13} />}
|
||||
loading={busy === slot.key}
|
||||
disabled={full}
|
||||
{...props}
|
||||
>
|
||||
{t('licensing.documents.upload')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</RequirePermission>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal
|
||||
opened={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title={t('documents.personal.confirmDelete.title')}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm">
|
||||
{t('documents.personal.confirmDelete.body', {
|
||||
name: deleteTarget?.originalName ?? '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button color="red" loading={busy === deleteTarget?.id} onClick={confirmDelete}>
|
||||
{t('documents.personal.confirmDelete.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
IconEye,
|
||||
IconHeartbeat,
|
||||
IconIdBadge2,
|
||||
IconInfoCircle,
|
||||
IconPaperclip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -37,13 +34,12 @@ import {
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
useGetMySeafarerDocumentsQuery,
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||
type SeafarerDocument,
|
||||
type SeafarerRecordStatus,
|
||||
} from '@ema-platform/api';
|
||||
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
|
||||
import { documentSlots } from '../../seafarer-registration/components/RegistrationDocuments';
|
||||
import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots';
|
||||
|
||||
type Preview = { url: string; title: string };
|
||||
|
||||
@@ -194,18 +190,10 @@ export function DocumentVaultPage() {
|
||||
const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery();
|
||||
const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery();
|
||||
const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: registrationData, isLoading: loadingRegistration } =
|
||||
useGetMySeafarerRegistrationQuery();
|
||||
|
||||
const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
|
||||
const registration = registrationData?.registration ?? null;
|
||||
const { data: registrationFiles } = useGetAttachmentsQuery(
|
||||
{ ownerType: 'SEAFARER_REGISTRATION', ownerId: registration?.id ?? '' },
|
||||
{ skip: !registration },
|
||||
);
|
||||
|
||||
async function openCertificate(licenseId: string) {
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
@@ -436,117 +424,12 @@ export function DocumentVaultPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Personal documents from the seafarer registration ───────── */}
|
||||
{/* ── The applicant's own document vault ──────────────────────── */}
|
||||
<Tabs.Panel value="personal">
|
||||
{loadingRegistration ? (
|
||||
<Loader size="sm" type="oval" />
|
||||
) : !registration ? (
|
||||
// Personal documents are uploaded against a registration, so
|
||||
// without one there is nothing to show — and nowhere to put an
|
||||
// upload either.
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
<Stack gap="xs" align="flex-start">
|
||||
<Text fz="sm">{t('documents.personal.noRegistration')}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
component={Link}
|
||||
to="/seafarer-registration"
|
||||
>
|
||||
{t('documents.personal.startRegistration')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('documents.personal.description')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{documentSlots(
|
||||
Boolean(registration.passportNumber),
|
||||
registration.nationality,
|
||||
).map((slot) => {
|
||||
const attachment = (registrationFiles ?? []).find(
|
||||
(a) => a.documentKey === slot.key,
|
||||
);
|
||||
const files = attachment?.files ?? [];
|
||||
return (
|
||||
<Card
|
||||
key={slot.key}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{
|
||||
borderStyle: files.length ? 'solid' : 'dashed',
|
||||
borderColor: files.length
|
||||
? 'var(--mantine-color-teal-4)'
|
||||
: slot.isRequired
|
||||
? 'var(--mantine-color-orange-4)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>
|
||||
{slot.name}
|
||||
</Text>
|
||||
{slot.description && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{slot.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={files.length ? 'teal' : slot.isRequired ? 'orange' : 'gray'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{files.length
|
||||
? t('documents.personal.uploaded')
|
||||
: t('documents.personal.missing')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
{files.length === 0 ? (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('documents.files.none')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{files.map((file) => (
|
||||
<Group key={file.id} gap={6} wrap="nowrap">
|
||||
<IconPaperclip size={14} />
|
||||
{file.url ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
fz="xs"
|
||||
onClick={() =>
|
||||
setPreview({
|
||||
url: file.url as string,
|
||||
title: file.originalName,
|
||||
})
|
||||
}
|
||||
>
|
||||
{file.originalName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fz="xs">{file.originalName}</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
{/* Owned by the profile, not by a registration or an application:
|
||||
the slots are configured in the backoffice and the files travel
|
||||
with the person. */}
|
||||
<PersonalDocumentSlots onPreview={setPreview} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
@@ -1326,11 +1326,29 @@ export const am: Translations = {
|
||||
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
|
||||
},
|
||||
personal: {
|
||||
description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
|
||||
noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።',
|
||||
startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
|
||||
description: 'የማንነትና የትምህርት ሰነዶችዎ። እዚህ አንድ ጊዜ ይስቀሉ፤ በመዝገብዎ ላይ ይቆያሉ።',
|
||||
empty: 'እስካሁን የተዋቀረ የግል ሰነድ የለም።',
|
||||
uploaded: 'ተሰቅሏል',
|
||||
missing: 'አልተሰቀለም',
|
||||
fileCount: '{{count}} ከ {{max}}',
|
||||
fileCountUnlimited_one: '{{count}} ፋይል',
|
||||
fileCountUnlimited_other: '{{count}} ፋይሎች',
|
||||
slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።',
|
||||
delete: 'ፋይል አስወግድ',
|
||||
confirmDelete: {
|
||||
title: 'ይህን ፋይል ያስወግዱ?',
|
||||
body: '"{{name}}"ን ያስወግዱ? በኋላ እንደገና መስቀል ይችላሉ።',
|
||||
confirm: 'አስወግድ',
|
||||
},
|
||||
errors: {
|
||||
unknown: 'ፋይሉ ሊቀመጥ አልቻለም። እንደገና ይሞክሩ።',
|
||||
unknown_document_key: 'ይህ ሰነድ አሁን አይሰበሰብም።',
|
||||
unsupported_document_type: 'ይህ የፋይል አይነት እዚህ አይፈቀድም። የተፈቀዱት፦ {{allowed}}።',
|
||||
document_too_large: 'ፋይሉ በጣም ትልቅ ነው።',
|
||||
document_file_required: 'የሚሰቀል ፋይል ይምረጡ።',
|
||||
slot_full: 'ይህ ሰነድ ቀድሞውኑ {{maxFiles}} ፋይል(ሎች) ይዟል። በምትኩ አንዱን ይተኩ።',
|
||||
document_file_not_found: 'ይህ ፋይል በመዝገብዎ ላይ የለም።',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1329,12 +1329,30 @@ export const en = {
|
||||
seaService: 'No sea-service records on file yet.',
|
||||
},
|
||||
personal: {
|
||||
description: 'The documents you submitted with your seafarer registration.',
|
||||
noRegistration:
|
||||
'You have no seafarer registration yet, so there are no personal documents on file.',
|
||||
startRegistration: 'Go to seafarer registration',
|
||||
description:
|
||||
'Your identity and education documents. Upload them once here and they stay on your record.',
|
||||
empty: 'No personal documents are configured yet.',
|
||||
uploaded: 'Uploaded',
|
||||
missing: 'Not uploaded',
|
||||
fileCount: '{{count}} of {{max}}',
|
||||
fileCountUnlimited_one: '{{count}} file',
|
||||
fileCountUnlimited_other: '{{count}} files',
|
||||
slotFull: 'This document is complete. Replace or remove a file to change it.',
|
||||
delete: 'Remove file',
|
||||
confirmDelete: {
|
||||
title: 'Remove this file?',
|
||||
body: 'Remove "{{name}}"? You can upload it again afterwards.',
|
||||
confirm: 'Remove',
|
||||
},
|
||||
errors: {
|
||||
unknown: 'The file could not be saved. Try again.',
|
||||
unknown_document_key: 'This document is no longer being collected.',
|
||||
unsupported_document_type: 'That file type is not accepted here. Allowed: {{allowed}}.',
|
||||
document_too_large: 'That file is too large.',
|
||||
document_file_required: 'Choose a file to upload.',
|
||||
slot_full: 'This document already holds {{maxFiles}} file(s). Replace one instead.',
|
||||
document_file_not_found: 'That file is no longer on your record.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './lib/features/location';
|
||||
export * from './lib/features/seafarer';
|
||||
export * from './lib/features/seafarer-registration';
|
||||
export * from './lib/features/seafarer-document';
|
||||
export * from './lib/features/personal-document';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||
|
||||
@@ -236,7 +236,9 @@ export const licensingApi = baseApi
|
||||
|
||||
createDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
||||
// No `licenseTypeId` means a personal document, required for every
|
||||
// licence and served from the applicant's own vault.
|
||||
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
|
||||
@@ -252,7 +252,12 @@ export interface StcwCapacityRow {
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
/**
|
||||
* Null for a personal document — one every applicant keeps in their own
|
||||
* vault regardless of what they apply for, rather than a slot on one
|
||||
* licence's application form.
|
||||
*/
|
||||
licenseTypeId: string | null;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
@@ -263,6 +268,8 @@ export interface DocumentRequirement {
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
|
||||
maxFiles: number | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
2
libs/api/src/lib/features/personal-document/index.ts
Normal file
2
libs/api/src/lib/features/personal-document/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './personal-document.types';
|
||||
export * from './personal-document-api';
|
||||
@@ -0,0 +1,69 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type { PersonalDocumentSlot } from './personal-document.types';
|
||||
|
||||
const TAG = 'PersonalDocument' as const;
|
||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
|
||||
/**
|
||||
* The applicant's own documents — identity card, photograph, education —
|
||||
* kept against their profile rather than any one application, so they survive
|
||||
* having no seafarer registration yet.
|
||||
*
|
||||
* Bodies are `FormData`: `fetchBaseQuery` passes one through untouched and
|
||||
* never sets `Content-Type`, so the browser's multipart boundary survives.
|
||||
* Going through RTK rather than the raw `uploadDocument` helper is what buys
|
||||
* the automatic refetch after every write.
|
||||
*/
|
||||
export const personalDocumentApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
|
||||
query: () => ({ url: '/profiles/me/documents' }),
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
/** One file per call — front and back of an ID are two uploads. */
|
||||
uploadPersonalDocument: builder.mutation<
|
||||
PersonalDocumentSlot,
|
||||
{ documentKey: string; file: File }
|
||||
>({
|
||||
query: ({ documentKey, file }) => {
|
||||
const body = new FormData();
|
||||
body.append('documentKey', documentKey);
|
||||
body.append('file', file, file.name);
|
||||
return { url: '/profiles/me/documents', method: 'POST', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
/** Swaps one file in place; the slot comes from the file, not the caller. */
|
||||
replacePersonalDocumentFile: builder.mutation<
|
||||
PersonalDocumentSlot,
|
||||
{ fileId: string; file: File }
|
||||
>({
|
||||
query: ({ fileId, file }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file, file.name);
|
||||
return { url: `/profiles/me/documents/files/${fileId}`, method: 'PUT', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({
|
||||
query: (fileId) => ({
|
||||
url: `/profiles/me/documents/files/${fileId}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMyPersonalDocumentsQuery,
|
||||
useUploadPersonalDocumentMutation,
|
||||
useReplacePersonalDocumentFileMutation,
|
||||
useDeletePersonalDocumentFileMutation,
|
||||
} = personalDocumentApi;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AttachmentFile, Bilingual } from '../licensing/licensing.types';
|
||||
|
||||
/**
|
||||
* One slot in the applicant's personal document vault, with whatever they
|
||||
* have put in it.
|
||||
*
|
||||
* The slot itself is configuration: a document requirement that names no
|
||||
* licence type applies to every licence, so the backoffice adds and retires
|
||||
* these without a release. That is why the label, the accepted types and the
|
||||
* limits arrive from the API rather than living in the portal.
|
||||
*/
|
||||
export interface PersonalDocumentSlot {
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description: Bilingual | null;
|
||||
/** How many files the slot holds; null means as many as the holder has. */
|
||||
maxFiles: number | null;
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
sortOrder: number;
|
||||
files: AttachmentFile[];
|
||||
}
|
||||
Reference in New Issue
Block a user