diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
index 9569913bc..387e1e5ba 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx
@@ -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={{isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}}
>
+ {personal && (
+ }
+ 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.',
+ )}
+
+ )}
+
{
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}
+ {/* 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 && (
+
+ {t("review.documents.fromVault", "From My Documents")}
+
+ )}
{verdict && (
(null);
const [error, setError] = useState(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 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')}
)}
+ {/* 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 && (
+
+ {t('licensing.documents.fromVault')}
+
+ )}
{requirement.description && (
{localized(requirement.description)}
)}
- {existing?.files?.[0] && (
-
- {existing.files[0].originalName} ·{' '}
- {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
+ {files.map((file) => (
+
+ {file.originalName} · {(Number(file.sizeBytes) / 1024).toFixed(0)} KB
- )}
+ ))}
{flagRemark && (
{t('licensing.documents.officerRemark', { name: flagRemark })}
@@ -174,20 +186,26 @@ export function DocumentSlots({
- {fileUrl && (
-
- )}
+ {files
+ .filter((file) => file.url)
+ .map((file, index) => (
+
+ ))}
{!locked && (
{
@@ -222,11 +240,12 @@ export function DocumentSlots({
);
})}
- setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
+ mimeType={preview?.mimeType}
/>
);
diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
index 2a75eeae5..16e46e7cb 100644
--- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
+++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx
@@ -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(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 ;
}
@@ -578,6 +619,7 @@ export function LicenseApplicationPage() {
const currentStep = steps[active];
+
/**
* Checks one step before moving past it.
*
diff --git a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx
index e1fb715e3..cc3c19b21 100644
--- a/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx
+++ b/apps/portal/src/app/features/seafarer-registration/components/RegistrationDocuments.tsx
@@ -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,
+): RegistrationSlot[] {
+ const context = { identity: answers, personal: answers } as Record<
+ string,
+ Record
+ >;
+ 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;
attachments: Attachment[];
readOnly?: boolean;
onUploaded: () => void;
}) {
+ const localized = useLocalized();
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const resetRefs = useRef void>>({});
@@ -66,9 +97,10 @@ export function RegistrationDocuments({
{error}
)}
- {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 (
- {slot.name}
+ {localized(slot.name)}
{!slot.isRequired && (
@@ -95,10 +127,16 @@ export function RegistrationDocuments({
uploaded
)}
+ {/* Copied from My Documents rather than uploaded here. */}
+ {fromVault && (
+
+ from My Documents
+
+ )}
{slot.description && (
- {slot.description}
+ {localized(slot.description)}
)}
{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) => (