feat: implement auto-filling of license and registration document slots from personal vault and add UI labels for vault-sourced files

This commit is contained in:
estifanos
2026-08-31 08:18:27 +00:00
parent f1d6640629
commit 317a1532b1
13 changed files with 288 additions and 51 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Divider,
@@ -12,6 +13,7 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import {
@@ -225,6 +227,20 @@ export function DocumentRequirementEditorDrawer({
title={<Text fw={700}>{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}</Text>}
>
<Stack gap="md">
{personal && (
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
title={t('certReq.doc.keyMatchTitle', 'Keys are what link this to a licence')}
>
{t(
'certReq.doc.keyMatchBody',
'An applicant who holds this document has it copied onto any licence application asking for a requirement with the same key. A key that matches nothing is still collected — it simply never fills a form by itself.',
)}
</Alert>
)}
<TextInput
label={t('certReq.doc.key', 'Key')}
placeholder="bank_letter"
@@ -232,7 +248,16 @@ export function DocumentRequirementEditorDrawer({
value={draft.key}
error={keyError}
disabled={!isNew}
description={isNew ? t('certReq.doc.keyHelp', 'Stable slug identifying this document slot') : t('certReq.doc.keyLocked', 'Key cannot change once created')}
description={
!isNew
? t('certReq.doc.keyLocked', 'Key cannot change once created')
: personal
? t(
'certReq.doc.keyHelpPersonal',
'Use the same key as the licence document this should answer — an application is filled from this document only when the two keys match exactly.',
)
: t('certReq.doc.keyHelp', 'Stable slug identifying this document slot')
}
// The value is read out of the event first: a functional updater
// runs after React has released the event, so `currentTarget` is
// null by the time it would be read inside one.

View File

@@ -174,6 +174,7 @@ export function DocumentsTab({
{attachments.map((attachment) => {
const file = attachment.files?.[0];
const fromVault = Boolean(attachment.copiedFromAttachmentId);
const flagged = attachment.documentKey in flags;
const verdict = verdictFor(attachment.documentKey);
const pendingReject = attachment.documentKey in rejecting;
@@ -199,6 +200,14 @@ export function DocumentsTab({
requirementByKey.get(attachment.documentKey)?.name,
) || attachment.documentKey}
</Text>
{/* Filed from the applicant's own document vault rather
than uploaded against this application — worth knowing
when the same file turns up on several files. */}
{fromVault && (
<Badge size="xs" variant="light" color="blue">
{t("review.documents.fromVault", "From My Documents")}
</Badge>
)}
{verdict && (
<Tooltip
label={

View File

@@ -1148,6 +1148,7 @@ export const am: Translations = {
uploaded: "{{document}} ተጭኗል",
},
documents: {
fromVault: "ከሰነዶቼ",
completeness: "የሚያስፈልጉ ሰነዶች",
accepted: "ተቀባይነት አግኝቷል",
rejected: "ተቀባይነት አላገኘም",
@@ -1460,6 +1461,11 @@ export const am: Translations = {
emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።",
key: "ቁልፍ",
keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ",
keyHelpPersonal:
"ይህ ሰነድ ሊመልሰው ከሚገባው የፈቃድ ሰነድ ጋር አንድ አይነት ቁልፍ ይጠቀሙ — ሁለቱ ቁልፎች በትክክል ሲመሳሰሉ ብቻ ማመልከቻው ከዚህ ሰነድ ይሞላል።",
keyMatchTitle: "ከፈቃድ ጋር የሚያገናኘው ቁልፍ ነው",
keyMatchBody:
"ይህን ሰነድ የያዘ አመልካች፣ ተመሳሳይ ቁልፍ ያለው መስፈርት ለሚጠይቅ ማንኛውም የፈቃድ ማመልከቻ ሰነዱ ይገለበጥለታል። ከምንም ጋር የማይመሳሰል ቁልፍ አሁንም ይሰበሰባል — በራሱ ብቻ ቅጽ አይሞላም።",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyRequired: "ቁልፍ ያስፈልጋል",
name: "ስም",

View File

@@ -1155,6 +1155,7 @@ export const en = {
uploaded: 'Uploaded {{document}}',
},
documents: {
fromVault: 'From My Documents',
completeness: 'Required documents',
accepted: 'Accepted',
rejected: 'Rejected',
@@ -1466,6 +1467,11 @@ export const en = {
emptyKind: 'No document requirements for this application kind yet.',
key: 'Key',
keyHelp: 'Stable slug identifying this document slot',
keyHelpPersonal:
'Use the same key as the licence document this should answer — an application is filled from this document only when the two keys match exactly.',
keyMatchTitle: 'Keys are what link this to a licence',
keyMatchBody:
'An applicant who holds this document has it copied onto any licence application asking for a requirement with the same key. A key that matches nothing is still collected — it simply never fills a form by itself.',
keyLocked: 'Key cannot change once created',
keyRequired: 'Key is required',
name: 'Name',

View File

@@ -23,7 +23,7 @@ import {
type DocumentRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
import { FilePreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -69,7 +69,11 @@ export function DocumentSlots({
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
const [preview, setPreview] = useState<{
url: string;
title: string;
mimeType?: string | null;
} | null>(
null,
);
const resetRefs = useRef<Record<string, () => void>>({});
@@ -111,7 +115,8 @@ export function DocumentSlots({
{required.map((requirement) => {
const existing = attachments.find((a) => a.documentKey === requirement.key);
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const files = existing?.files ?? [];
const fromVault = Boolean(existing?.copiedFromAttachmentId);
const flagRemark = flagged[requirement.key];
const locked =
readOnly ||
@@ -154,18 +159,25 @@ export function DocumentSlots({
{t('licensing.documents.uploaded')}
</Badge>
)}
{/* Filled from the applicant's own vault rather than
uploaded here — without saying so, a file they never
attached to this application looks like a mistake. */}
{fromVault && !flagRemark && (
<Badge size="xs" variant="light" color="blue">
{t('licensing.documents.fromVault')}
</Badge>
)}
</Group>
{requirement.description && (
<Text size="xs" c="dimmed" mt={2}>
{localized(requirement.description)}
</Text>
)}
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate>
{existing.files[0].originalName} ·{' '}
{(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
{files.map((file) => (
<Text key={file.id} size="xs" c="dimmed" truncate>
{file.originalName} · {(Number(file.sizeBytes) / 1024).toFixed(0)} KB
</Text>
)}
))}
{flagRemark && (
<Text size="xs" c="orange.7" mt={4}>
{t('licensing.documents.officerRemark', { name: flagRemark })}
@@ -174,20 +186,26 @@ export function DocumentSlots({
</div>
<Group gap="xs" wrap="nowrap">
{fileUrl && (
<Button
size="xs"
variant="subtle"
onClick={() =>
setPreview({
url: fileUrl,
title: localized(requirement.name),
})
}
>
{t('licensing.documents.view')}
</Button>
)}
{files
.filter((file) => file.url)
.map((file, index) => (
<Button
key={file.id}
size="xs"
variant="subtle"
onClick={() =>
setPreview({
url: file.url as string,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{files.length > 1
? `${t('licensing.documents.view')} ${index + 1}`
: t('licensing.documents.view')}
</Button>
))}
{!locked && (
<FileButton
resetRef={(r) => {
@@ -222,11 +240,12 @@ export function DocumentSlots({
</Card>
);
})}
<PdfPreviewModal
<FilePreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
mimeType={preview?.mimeType}
/>
</Stack>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
@@ -44,6 +44,7 @@ import {
useAddStaffMutation,
useCreateApplicationMutation,
useGetApplicationQuery,
useFillApplicationDocumentsFromVaultMutation,
useGetAttachmentsQuery,
useGetLicenseTypeRequirementsQuery,
useGetMyVesselsQuery,
@@ -430,6 +431,46 @@ export function LicenseApplicationPage() {
if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1));
}, [active, steps.length]);
/**
* Fills the document slots from the applicant's own vault the first time
* they reach that step.
*
* Someone who already keeps their passport under My Documents should find it
* here rather than being asked to go and fetch the same file again. The
* server only fills empty slots, so this cannot overwrite anything they
* uploaded, and calling it twice costs one query.
*/
const [fillFromVault] = useFillApplicationDocumentsFromVaultMutation();
const filledForApplication = useRef<string | null>(null);
useEffect(() => {
const status = application?.status;
const editable =
status === "DRAFT" ||
status === "RESUBMIT_REQUIRED" ||
(status === "SUBMITTED" && !application?.assignedOfficerId);
if (steps[active]?.kind !== "documents" || !appId || !editable) return;
if (filledForApplication.current === appId) return;
filledForApplication.current = appId;
fillFromVault(appId)
.unwrap()
.then((result) => {
// Nothing was copied: no need to disturb the queries.
if (result.filled.length) refetchAttachments();
})
// A vault that cannot be read is not a reason to block the form — the
// applicant can still upload by hand.
.catch(() => undefined);
}, [
steps,
active,
appId,
application?.status,
application?.assignedOfficerId,
fillFromVault,
refetchAttachments,
]);
if (loadingConfig || !config || !appId || !application) {
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
}
@@ -578,6 +619,7 @@ export function LicenseApplicationPage() {
const currentStep = steps[active];
/**
* Checks one step before moving past it.
*

View File

@@ -2,38 +2,69 @@ import { useRef, useState } from 'react';
import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } from '@mantine/core';
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
isEthiopianNationality,
conditionHolds,
uploadDocument,
useLocalized,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
/** The document slots a registration asks for. */
export function documentSlots(passportDeclared: boolean, nationality?: string | null) {
const ethiopian = isEthiopianNationality(nationality);
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
...d,
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
/** A configured requirement, with whether this applicant must supply it. */
export type RegistrationSlot = DocumentRequirement & { isRequired: boolean };
/**
* The document slots a registration asks for.
*
* Configuration, not a constant: these are `document_requirements` rows
* against the SEAFARER_REGISTRATION licence type, the same ones the backoffice
* edits and the server validates against. A conditional requirement — the
* passport copy, wanted once a passport number is declared — is evaluated
* against the answers under `identity`, exactly as the server does it, so the
* wizard and the submit check can never disagree about what is being asked
* for.
*/
export function documentSlots(
requirements: DocumentRequirement[],
answers: Record<string, unknown>,
): RegistrationSlot[] {
const context = { identity: answers, personal: answers } as Record<
string,
Record<string, unknown>
>;
return requirements
.filter(
(requirement) =>
requirement.mode !== 'CONDITIONAL' ||
conditionHolds(requirement.conditionExpression, context),
)
.map((requirement) => ({
...requirement,
isRequired:
requirement.mode === 'ALWAYS' ||
(requirement.mode === 'CONDITIONAL' &&
conditionHolds(requirement.conditionExpression, context)),
}));
}
export function RegistrationDocuments({
registrationId,
passportDeclared,
nationality,
requirements,
answers,
attachments,
readOnly,
onUploaded,
}: {
registrationId: string;
passportDeclared: boolean;
nationality?: string | null;
requirements: DocumentRequirement[];
/** The registration's answers, for evaluating conditional requirements. */
answers: Record<string, unknown>;
attachments: Attachment[];
readOnly?: boolean;
onUploaded: () => void;
}) {
const localized = useLocalized();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});
@@ -66,9 +97,10 @@ export function RegistrationDocuments({
{error}
</Alert>
)}
{documentSlots(passportDeclared, nationality).map((slot) => {
{documentSlots(requirements, answers).map((slot) => {
const existing = attachments.find((a) => a.documentKey === slot.key);
const uploaded = Boolean(existing?.files?.length);
const fromVault = Boolean(existing?.copiedFromAttachmentId);
return (
<Card
key={slot.key}
@@ -83,7 +115,7 @@ export function RegistrationDocuments({
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{slot.name}
{localized(slot.name)}
</Text>
{!slot.isRequired && (
<Badge size="xs" variant="light" color="gray">
@@ -95,10 +127,16 @@ export function RegistrationDocuments({
uploaded
</Badge>
)}
{/* Copied from My Documents rather than uploaded here. */}
{fromVault && (
<Badge size="xs" variant="light" color="blue">
from My Documents
</Badge>
)}
</Group>
{slot.description && (
<Text size="xs" c="dimmed" mt={2}>
{slot.description}
{localized(slot.description)}
</Text>
)}
{existing?.files?.[0] && (
@@ -119,7 +157,7 @@ export function RegistrationDocuments({
if (r) resetRefs.current[slot.key] = r;
}}
onChange={(file) => handle(slot.key, file)}
accept={slot.accept}
accept={slot.allowedMimeTypes?.join(',')}
>
{(props) => (
<Button

View File

@@ -6,6 +6,7 @@ import {
useGetActiveDepartmentsQuery,
useLocalized,
type Attachment,
type DocumentRequirement,
type SaveSeafarerRegistration,
} from '@ema-platform/api';
import { documentSlots } from './RegistrationDocuments';
@@ -14,9 +15,12 @@ import { documentSlots } from './RegistrationDocuments';
export function RegistrationSummary({
answers,
attachments,
requirements = [],
}: {
answers: SaveSeafarerRegistration;
attachments?: Attachment[];
/** The configured document slots, so the summary lists what was asked for. */
requirements?: DocumentRequirement[];
}) {
const localized = useLocalized();
const { data: departments } = useGetActiveDepartmentsQuery();
@@ -59,13 +63,13 @@ export function RegistrationSummary({
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{documentSlots(Boolean(answers.passportNumber)).map((slot) => {
{documentSlots(requirements, answers as Record<string, unknown>).map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
return (
<Table.Tr key={slot.key}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{slot.name}
{localized(slot.name)}
</Text>
</Table.Td>
<Table.Td>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
@@ -28,6 +28,8 @@ import {
isEthiopianNationality,
useCancelSeafarerRegistrationMutation,
useGetAttachmentsQuery,
useFillRegistrationDocumentsFromVaultMutation,
useGetLicenseTypeRequirementsQuery,
useGetMySeafarerRegistrationQuery,
useSaveSeafarerRegistrationMutation,
useStartSeafarerRegistrationMutation,
@@ -167,12 +169,50 @@ export function SeafarerRegistrationPage() {
{ skip: !registration },
);
// What a registration must supply is configuration — the same requirement
// rows the backoffice edits and the server validates against — so the wizard
// reads them rather than carrying its own copy of the list.
const { data: registrationConfig } = useGetLicenseTypeRequirementsQuery({
idOrKey: 'SEAFARER_REGISTRATION',
});
const documentRequirements = useMemo(
() => registrationConfig?.documentRequirements ?? [],
[registrationConfig],
);
const [active, setActive] = useState(0);
const [viewingSummary, setViewingSummary] = useState(true);
const [form, setForm] = useState<SaveSeafarerRegistration>({});
const [errors, setErrors] = useState<Partial<Record<AnswerKey, string>>>({});
const [issues, setIssues] = useState<ValidationIssue[]>([]);
/**
* Fills the document slots from the applicant's own vault the first time
* they reach that step — a passport already kept under My Documents should
* not be asked for a second time. The server only fills empty slots.
*/
const [fillFromVault] = useFillRegistrationDocumentsFromVaultMutation();
const filledForRegistration = useRef<string | null>(null);
useEffect(() => {
const id = registration?.id;
const editable =
registration?.status === 'DRAFT' ||
registration?.status === 'RESUBMIT_REQUIRED';
if (active !== 3 || !id || !editable) return;
if (filledForRegistration.current === id) return;
filledForRegistration.current = id;
fillFromVault(id)
.unwrap()
.then((result) => {
if (result.filled.length) refetchAttachments();
})
// Nothing to fill, or a vault that cannot be read: the applicant uploads
// by hand, exactly as before.
.catch(() => undefined);
}, [active, registration?.id, registration?.status, fillFromVault, refetchAttachments]);
const accountName = accountUser?.name?.en ?? profile?.user?.name?.en;
const isDraft = registration?.status === 'DRAFT';
@@ -256,9 +296,9 @@ export function SeafarerRegistrationPage() {
}
if (index === 3) {
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
const missing = documentSlots(Boolean(form.passportNumber), form.nationality)
const missing = documentSlots(documentRequirements, form as Record<string, unknown>)
.filter((d) => d.isRequired && !supplied.has(d.key))
.map((d) => d.name);
.map((d) => d.name?.en ?? d.key);
if (missing.length) {
notifications.show({
color: 'red',
@@ -412,7 +452,11 @@ export function SeafarerRegistrationPage() {
{showSummary && (
<Paper withBorder p="lg" radius="md">
<RegistrationSummary answers={answersOf(registration)} attachments={attachments} />
<RegistrationSummary
answers={answersOf(registration)}
attachments={attachments}
requirements={documentRequirements}
/>
</Paper>
)}
@@ -435,8 +479,8 @@ export function SeafarerRegistrationPage() {
{active === 3 && (
<RegistrationDocuments
registrationId={registration.id}
passportDeclared={Boolean(form.passportNumber)}
nationality={form.nationality}
requirements={documentRequirements}
answers={form as Record<string, unknown>}
attachments={attachments}
readOnly={readOnly}
onUploaded={refetchAttachments}
@@ -457,7 +501,11 @@ export function SeafarerRegistrationPage() {
</Grid>
<Divider my="md" />
<Title order={5}>Review</Title>
<RegistrationSummary answers={form} attachments={attachments} />
<RegistrationSummary
answers={form}
attachments={attachments}
requirements={documentRequirements}
/>
</Stack>
)}

View File

@@ -841,6 +841,7 @@ export const am: Translations = {
documents: {
conditional: 'በሁኔታ ላይ የተመሠረተ',
uploaded: 'ተሰቅሏል',
fromVault: 'ከሰነዶቼ',
officerRemark: 'ባለሥልጣን፦ {{name}}',
view: 'ይመልከቱ',
replace: 'ይተኩ',

View File

@@ -843,6 +843,7 @@ export const en = {
documents: {
conditional: 'conditional',
uploaded: 'uploaded',
fromVault: 'from My Documents',
officerRemark: 'Officer: {{name}}',
view: 'View',
replace: 'Replace',

View File

@@ -524,6 +524,36 @@ export const licensingApi = baseApi
providesTags: (_r, _e, arg) => [itemTag('Attachment', arg.ownerId)],
}),
/**
* Copies the applicant's personal documents onto an application, for
* every requirement their vault answers and this application has not
* already got. Idempotent — a filled slot is never touched.
*/
fillApplicationDocumentsFromVault: builder.mutation<
{ filled: string[] },
string
>({
query: (applicationId) => ({
url: `/license-applications/${applicationId}/documents/fill-from-vault`,
method: 'POST',
}),
invalidatesTags: (_r, error, applicationId) =>
error ? [] : [itemTag('Attachment', applicationId)],
}),
/** The same, for a seafarer registration. */
fillRegistrationDocumentsFromVault: builder.mutation<
{ filled: string[] },
string
>({
query: (registrationId) => ({
url: `/seafarer-registrations/${registrationId}/documents/fill-from-vault`,
method: 'POST',
}),
invalidatesTags: (_r, error, registrationId) =>
error ? [] : [itemTag('Attachment', registrationId)],
}),
deleteAttachment: builder.mutation<unknown, { attachmentId: string; ownerId: string }>({
query: ({ attachmentId }) => ({
url: `/attachments/${attachmentId}`,
@@ -1342,6 +1372,8 @@ export const {
useResolveRemarkMutation,
useResubmitApplicationMutation,
useGetAttachmentsQuery,
useFillApplicationDocumentsFromVaultMutation,
useFillRegistrationDocumentsFromVaultMutation,
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,

View File

@@ -433,6 +433,12 @@ export interface Attachment {
ownerType: string;
ownerId: string;
documentKey: string;
/**
* Set when this was filled from the applicant's personal document vault
* rather than uploaded here — what both apps badge as "From My Documents".
* Null for an ordinary upload, and cleared the moment the file is replaced.
*/
copiedFromAttachmentId?: string | null;
title: string | null;
validFrom: string | null;
validTo: string | null;