feat: add support for global personal document requirements in applicant vaults

This commit is contained in:
estifanos
2026-08-28 08:54:10 +00:00
parent 627fcfbfde
commit f255621672
15 changed files with 780 additions and 171 deletions

View File

@@ -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

View File

@@ -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 applicants 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>
);
}

View File

@@ -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')}

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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: 'ይህ ፋይል በመዝገብዎ ላይ የለም።',
},
},
},
};

View File

@@ -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.',
},
},
},
};

View File

@@ -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';

View File

@@ -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')]),

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
export * from './personal-document.types';
export * from './personal-document-api';

View File

@@ -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;

View File

@@ -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[];
}