Merge pull request #50 from Tria-plc/estif-branch-1

document vault integration and fixes
This commit is contained in:
Nati Nigussie
2026-08-31 14:09:03 +03:00
committed by GitHub
15 changed files with 786 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

@@ -36,6 +36,7 @@ import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { FieldEditorDrawer } from './FieldEditorDrawer';
import { SectionEditorDrawer } from './SectionEditorDrawer';
import { StaffRolesCard } from './StaffRolesCard';
function moveItem<T>(list: T[], index: number, direction: -1 | 1): T[] {
const target = index + direction;
@@ -284,6 +285,8 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
</Stack>
)}
<StaffRolesCard licenseType={licenseType} />
<SectionEditorDrawer
opened={sectionDrawer !== null}
onClose={() => setSectionDrawer(null)}

View File

@@ -0,0 +1,443 @@
import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Checkbox,
Divider,
Drawer,
Group,
Modal,
NumberInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter, PageLoader } from '@ema-platform/ui';
import {
useCreateStaffRoleRequirementMutation,
useDeleteStaffRoleRequirementMutation,
useGetStaffRoleRequirementsQuery,
useLocalized,
useUpdateStaffRoleRequirementMutation,
type LicenseType,
type StaffEvidenceRequirement,
type StaffRoleRequirement,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
const ROLE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
type Draft = Omit<StaffRoleRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
function emptyDraft(sortOrder: number): Draft {
return {
roleKey: '',
name: { en: '', am: '' },
minCount: 1,
maxCount: null,
requiresExperience: false,
minYearsExperience: null,
requiredEvidence: [],
sortOrder,
};
}
/**
* The staff a licence type demands, and the evidence each of them must
* supply — the wizard's Staff step, which lives in its own table rather than
* in `formSchema` and so had no editor at all: an administrator could see FF
* asking for two ERB-certified transit employees but could not change it
* without a re-seed.
*
* Rows save on confirm, like the document requirements tab — deliberately not
* folded into the schema draft above, whose "Save schema" button replaces one
* jsonb column in a single PUT.
*/
export function StaffRolesCard({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data, isLoading } = useGetStaffRoleRequirementsQuery();
const [createRole, { isLoading: creating }] = useCreateStaffRoleRequirementMutation();
const [updateRole, { isLoading: updating }] = useUpdateStaffRoleRequirementMutation();
const [deleteRole] = useDeleteStaffRoleRequirementMutation();
const [editing, setEditing] = useState<{ role: StaffRoleRequirement | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<StaffRoleRequirement | null>(null);
const roles = useMemo(
() =>
(data?.items ?? [])
.filter((r) => r.licenseTypeId === licenseType.id)
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder),
[data, licenseType.id],
);
async function handleSave(draft: Draft) {
const target = editing?.role;
const ok = await run(
() =>
target
? updateRole({ id: target.id, ...draft }).unwrap()
: createRole({ ...draft, licenseTypeId: licenseType.id }).unwrap(),
target
? t('certReq.staff.updated', 'Staff role updated')
: t('certReq.staff.created', 'Staff role added'),
);
if (ok) setEditing(null);
}
async function confirmDelete() {
if (!deleteTarget) return;
const ok = await run(
() => deleteRole(deleteTarget.id).unwrap(),
t('certReq.staff.deleted', 'Staff role removed'),
);
if (ok) setDeleteTarget(null);
}
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" mb="sm">
<div>
<Title order={5}>{t('certReq.staff.title', 'Staff roles')}</Title>
<Text fz="xs" c="dimmed">
{t(
'certReq.staff.subtitle',
'The Staff step of this licence type: who the applicant must register and the evidence each of them uploads. Saved on confirm, separately from the sections above.',
)}
</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditing({ role: null })}
>
{t('certReq.staff.add', 'Add staff role')}
</Button>
</Group>
{isLoading ? (
<PageLoader label={t('certReq.staff.loading', 'Loading staff roles…')} height={120} />
) : roles.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
{t('certReq.staff.empty', 'No staff roles — this licence type has no Staff step.')}
</Text>
) : (
<Stack gap="xs">
{roles.map((role) => (
<Card key={role.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(role.name) || role.roleKey}</Text>
<Badge size="xs" variant="light">
{role.maxCount != null && role.maxCount === role.minCount
? t('certReq.staff.exactly', 'exactly {{n}}', { n: role.minCount })
: t('certReq.staff.range', 'min {{min}}{{max}}', {
min: role.minCount,
max: role.maxCount != null ? ` · max ${role.maxCount}` : '',
})}
</Badge>
{role.requiresExperience && (
<Badge size="xs" color="violet" variant="light">
{t('certReq.staff.experience', 'experience')}
{role.minYearsExperience ? ` · ${role.minYearsExperience}y` : ''}
</Badge>
)}
</Group>
<Text fz="xs" c="dimmed" truncate>
key: {role.roleKey}
{role.requiredEvidence.length > 0 &&
` · ${role.requiredEvidence
.map((e) => `${e.docKey}${e.mandatory ? '*' : ''}`)
.join(', ')}`}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => setEditing({ role })}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(role)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<StaffRoleEditorDrawer
opened={editing !== null}
onClose={() => setEditing(null)}
role={editing?.role ?? null}
nextSortOrder={roles.length + 1}
onSave={handleSave}
saving={creating || updating}
/>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t('certReq.staff.delete', 'Delete staff role')}
size="sm"
>
<Stack gap="md">
<Text fz="sm">
{t('certReq.staff.deleteConfirm', 'Remove "{{name}}" from this licence type\'s Staff step?', {
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.roleKey : '',
})}
</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>
);
}
function StaffRoleEditorDrawer({
opened,
onClose,
role,
nextSortOrder,
onSave,
saving,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new role. */
role: StaffRoleRequirement | null;
nextSortOrder: number;
onSave: (draft: Draft) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Draft>(emptyDraft(nextSortOrder));
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !role;
useEffect(() => {
if (!opened) return;
setDraft(
role
? {
roleKey: role.roleKey,
name: { ...role.name },
minCount: role.minCount,
maxCount: role.maxCount,
requiresExperience: role.requiresExperience,
minYearsExperience: role.minYearsExperience,
requiredEvidence: role.requiredEvidence.map((e) => ({ ...e, label: { ...e.label } })),
sortOrder: role.sortOrder,
}
: emptyDraft(nextSortOrder),
);
setKeyError(null);
}, [opened, role, nextSortOrder]);
function patchEvidence(index: number, patch: Partial<StaffEvidenceRequirement>) {
setDraft((d) => ({
...d,
requiredEvidence: d.requiredEvidence.map((e, i) => (i === index ? { ...e, ...patch } : e)),
}));
}
function save() {
const roleKey = draft.roleKey.trim();
if (!ROLE_KEY_PATTERN.test(roleKey)) {
setKeyError(
t(
'certReq.staff.keyInvalid',
'Key must start with a letter and contain only letters, numbers, underscores',
),
);
return;
}
if (!draft.name.en?.trim()) return;
// An evidence row with no docKey renders an upload slot nothing can be
// attached to, so it is dropped rather than saved half-filled.
onSave({
...draft,
roleKey,
requiredEvidence: draft.requiredEvidence.filter((e) => e.docKey.trim()),
});
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={
<Text fw={700}>
{isNew ? t('certReq.staff.add', 'Add staff role') : t('certReq.staff.edit', 'Edit staff role')}
</Text>
}
>
<Stack gap="md">
<TextInput
label={t('certReq.staff.roleKey', 'Role key')}
placeholder="TRANSIT_CUSTOMS"
required
value={draft.roleKey}
error={keyError}
disabled={!isNew}
description={
isNew
? t('certReq.staff.roleKeyHelp', 'Letters, numbers and underscores only — identifies the role on submitted staff')
: t('certReq.staff.roleKeyLocked', 'Key cannot change once created — staff already registered reference it')
}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, roleKey: value }));
}}
/>
<BilingualInput
label={t('certReq.staff.name', 'Role name')}
required
value={{ en: draft.name.en ?? '', am: draft.name.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
/>
<Group grow>
<NumberInput
label={t('certReq.staff.minCount', 'Minimum people')}
min={0}
value={draft.minCount}
onChange={(v) => setDraft((d) => ({ ...d, minCount: typeof v === 'number' ? v : 0 }))}
description={t('certReq.staff.minCountHelp', '0 makes the role optional')}
/>
<NumberInput
label={t('certReq.staff.maxCount', 'Maximum people')}
min={0}
value={draft.maxCount ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, maxCount: typeof v === 'number' ? v : null }))}
description={t('certReq.staff.maxCountHelp', 'Leave empty for no limit')}
/>
</Group>
<Checkbox
label={t('certReq.staff.requiresExperience', 'Requires prior experience')}
checked={draft.requiresExperience}
onChange={(e) => {
const { checked } = e.currentTarget;
setDraft((d) => ({
...d,
requiresExperience: checked,
minYearsExperience: checked ? d.minYearsExperience : null,
}));
}}
/>
{draft.requiresExperience && (
<NumberInput
label={t('certReq.staff.minYears', 'Minimum years of experience')}
min={0}
value={draft.minYearsExperience ?? ''}
onChange={(v) =>
setDraft((d) => ({ ...d, minYearsExperience: typeof v === 'number' ? v : null }))
}
/>
)}
<NumberInput
label={t('certReq.staff.sortOrder', 'Sort order')}
value={draft.sortOrder}
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : 0 }))}
/>
<Divider label={t('certReq.staff.evidence', 'Required evidence')} labelPosition="left" />
<Text fz="xs" c="dimmed">
{t(
'certReq.staff.evidenceHelp',
'One upload slot per document, asked of every person registered in this role.',
)}
</Text>
{draft.requiredEvidence.map((evidence, i) => (
<Card key={i} withBorder radius="sm" p="xs">
<Stack gap="xs">
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
size="xs"
label={t('certReq.staff.docKey', 'Document key')}
placeholder="cv"
value={evidence.docKey}
onChange={(e) => patchEvidence(i, { docKey: e.currentTarget.value })}
style={{ flex: 1 }}
/>
<BilingualInput
size="xs"
label={t('certReq.staff.docLabel', 'Label')}
value={{ en: evidence.label.en ?? '', am: evidence.label.am ?? '' }}
onChange={(v) => patchEvidence(i, { label: v })}
style={{ flex: 2 }}
/>
<Button
size="xs"
color="red"
variant="subtle"
px={6}
onClick={() =>
setDraft((d) => ({
...d,
requiredEvidence: d.requiredEvidence.filter((_, j) => j !== i),
}))
}
>
<IconTrash size={14} />
</Button>
</Group>
<Checkbox
size="xs"
label={t('certReq.staff.mandatory', 'Mandatory')}
checked={evidence.mandatory}
onChange={(e) => patchEvidence(i, { mandatory: e.currentTarget.checked })}
/>
</Stack>
</Card>
))}
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() =>
setDraft((d) => ({
...d,
requiredEvidence: [
...d.requiredEvidence,
{ docKey: '', label: { en: '', am: '' }, mandatory: true },
],
}))
}
>
{t('certReq.staff.addEvidence', 'Add evidence')}
</Button>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" loading={saving} onClick={save}>
{isNew ? t('certReq.staff.add', 'Add staff role') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

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

@@ -40,6 +40,7 @@ import type {
SavedQueueView,
SchemaIssue,
ServiceKind,
StaffRoleRequirement,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
@@ -99,6 +100,7 @@ const TAGS = [
'PickupAppointment',
'Department',
'Rank',
'StaffRoleRequirement',
// Owned by the personal-document slice; named here so declaring a mode of
// operation can invalidate the vault, whose slots depend on it.
'PersonalDocument',
@@ -366,6 +368,42 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// ----------------------------------------------- staff role requirements
/**
* Every staff role requirement, filtered by licence type at the call
* site — same reasoning as `getDocumentRequirements`: small
* configuration data with no pagination need.
*/
getStaffRoleRequirements: builder.query<Paginated<StaffRoleRequirement>, void>({
query: () => ({ url: '/staff-role-requirements' }),
providesTags: () => [listTag('StaffRoleRequirement')],
}),
createStaffRoleRequirement: builder.mutation<
StaffRoleRequirement,
Partial<StaffRoleRequirement> & { licenseTypeId: string; roleKey: string; name: StaffRoleRequirement['name'] }
>({
query: (body) => ({ url: '/staff-role-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
updateStaffRoleRequirement: builder.mutation<
StaffRoleRequirement,
{ id: string } & Partial<StaffRoleRequirement>
>({
query: ({ id, ...body }) => ({
url: `/staff-role-requirements/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
deleteStaffRoleRequirement: builder.mutation<unknown, string>({
query: (id) => ({ url: `/staff-role-requirements/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
// --------------------------------------------------- departments & ranks
/** Every department, for the admin editor. */
getDepartments: builder.query<Paginated<Department>, void>({
@@ -524,6 +562,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}`,
@@ -1311,6 +1379,10 @@ export const {
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetStaffRoleRequirementsQuery,
useCreateStaffRoleRequirementMutation,
useUpdateStaffRoleRequirementMutation,
useDeleteStaffRoleRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,
@@ -1342,6 +1414,8 @@ export const {
useResolveRemarkMutation,
useResubmitApplicationMutation,
useGetAttachmentsQuery,
useFillApplicationDocumentsFromVaultMutation,
useFillRegistrationDocumentsFromVaultMutation,
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,

View File

@@ -109,6 +109,14 @@ export interface FormFieldConfig {
showWhen?: FieldCondition;
readOnly?: boolean;
source?: string;
/**
* Marks `min` as bound to live configuration rather than stored with the
* field — `"licenseType.capitalThreshold"` for a capital amount. The server
* resolves it on every read, so `min` already carries the real floor by the
* time the wizard sees it; this is only here so the builder round-trips the
* binding instead of dropping it on save.
*/
minSource?: string;
sortOrder?: number;
}
@@ -356,6 +364,7 @@ export interface StaffEvidenceRequirement {
export interface StaffRoleRequirement {
id: string;
licenseTypeId: string;
roleKey: string;
name: Bilingual;
minCount: number;
@@ -364,6 +373,7 @@ export interface StaffRoleRequirement {
minYearsExperience: number | null;
requiredEvidence: StaffEvidenceRequirement[];
sortOrder: number;
isActive: boolean;
}
export interface LicenseTypeRequirements {
@@ -433,6 +443,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;