Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-31 13:08:21 +00:00
36 changed files with 3913 additions and 210 deletions

View File

@@ -1,4 +1,4 @@
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { Button, Group, Select, Stack, Text } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
@@ -12,12 +12,11 @@ interface Props {
ranks: Rank[];
rankId: string | null;
onRankChange: (id: string | null) => void;
/** Shown for context only; edited on Certificate Requirements → Behaviour. */
validityMonths: number;
onValidityChange: (months: number) => void;
currentValidityMonths?: number | null;
/** Non-null when the type is configured in days rather than months. */
validityDays?: number | null;
canEdit: boolean;
savingValidity: boolean;
onSaveValidity: () => void;
onNewVersion: () => void;
}
@@ -30,11 +29,8 @@ export function DesignerToolbar({
rankId,
onRankChange,
validityMonths,
onValidityChange,
currentValidityMonths,
validityDays,
canEdit,
savingValidity,
onSaveValidity,
onNewVersion,
}: Props) {
const { t } = useTranslation();
@@ -73,38 +69,31 @@ export function DesignerToolbar({
/>
)}
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => onValidityChange(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === currentValidityMonths}
onClick={onSaveValidity}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
{/* Read-only here. Validity is one policy decision with the renewal
window and the expiry reminders, so it is edited in one place —
Certificate Requirements → Behaviour — rather than from two screens
behind two different permissions. Still shown, because a designer
laying out a certificate that prints an expiry needs to see the term
it promises. */}
{typeId && (
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={500}>
{t('designer.validity', 'Valid for')}
</Text>
<Group gap={6} align="baseline">
<Text size="sm" fw={600}>
{validityDays != null
? t('designer.validityDays', '{{count}} days', { count: validityDays })
: t('designer.validityMonths', '{{count}} months', {
count: validityMonths,
})}
</Text>
<Text size="xs" c="dimmed">
{t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')}
</Text>
</Group>
</Stack>
)}
<div style={{ flex: 1 }} />

View File

@@ -29,7 +29,6 @@ import {
useGetRanksQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
@@ -88,7 +87,6 @@ export function CertificateDesignerPage() {
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const draft = useTemplateDraft(templates);
const run = useDesignerActions();
@@ -96,7 +94,6 @@ export function CertificateDesignerPage() {
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [validityMonths, setValidityMonths] = useState<number>(12);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
@@ -127,10 +124,6 @@ export function CertificateDesignerPage() {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
useEffect(() => {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
// Switching licence type leaves a stale rank selected from the previous
// type's ladder — reset to the type's default design.
useEffect(() => {
@@ -169,17 +162,9 @@ export function CertificateDesignerPage() {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths}
onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths}
validityMonths={selectedType?.validityMonths ?? 12}
validityDays={selectedType?.validityDays ?? null}
canEdit={canEdit}
savingValidity={savingValidity}
onSaveValidity={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
onNewVersion={startNewVersion}
/>

View File

@@ -0,0 +1,550 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Group,
MultiSelect,
NumberInput,
Paper,
Select,
Stack,
Switch,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth';
import {
useUpdateLicenseBehaviorMutation,
type CertificateCategory,
type CompletionEffect,
type LicenseType,
type ServiceKind,
type WorkflowProfile,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
/** The shape the form edits — every field the behaviour endpoint accepts. */
interface Draft {
workflowProfile: WorkflowProfile;
serviceKind: ServiceKind;
completionEffect: CompletionEffect | null;
certificateCategory: CertificateCategory | null;
requiresExamination: boolean;
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
requiresSeafarerRegistration: boolean;
requiresValidMedical: boolean;
minSeaTimeDays: number | null;
capitalThreshold: number | null;
validityMonths: number;
validityDays: number | null;
renewalWindowDays: number;
expiryReminderDays: number[];
requiresOperatorMode: boolean;
allowMultipleOpenDrafts: boolean;
requiresIssuanceScheduling: boolean;
uniqueFormKeyPath: string | null;
slaHours: number | null;
}
/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */
const REMINDER_OFFSETS = ['90', '60', '30', '14', '7'];
function toDraft(licenseType: LicenseType): Draft {
return {
workflowProfile: licenseType.workflowProfile ?? 'STANDARD',
serviceKind: licenseType.serviceKind ?? 'LICENSE',
completionEffect: licenseType.completionEffect ?? null,
certificateCategory: licenseType.certificateCategory ?? null,
requiresExamination: licenseType.requiresExamination ?? false,
inspectionRequired: licenseType.inspectionRequired ?? true,
issuesCertificate: licenseType.issuesCertificate ?? true,
renewalEnabled: licenseType.renewalEnabled ?? true,
requiresSeafarerRegistration:
licenseType.requiresSeafarerRegistration ?? false,
requiresValidMedical: licenseType.requiresValidMedical ?? false,
minSeaTimeDays: licenseType.minSeaTimeDays ?? null,
capitalThreshold:
licenseType.capitalThreshold == null
? null
: Number(licenseType.capitalThreshold),
validityMonths: licenseType.validityMonths ?? 12,
validityDays: licenseType.validityDays ?? null,
renewalWindowDays: licenseType.renewalWindowDays ?? 60,
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false,
requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false,
uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null,
slaHours: licenseType.slaHours ?? null,
};
}
/**
* How one licence type behaves: the course it runs, who may apply, when it
* renews and what rules the applicant meets.
*
* These were seed-only until now — changing an SLA target or a renewal window
* meant editing a file and redeploying. They are read live off the licence
* type rather than snapshotted onto applications, so a change here applies to
* files already in the queue as well as new ones. For the three settings that
* decide an application's course the server refuses the change outright while
* anything is still awaiting a decision, rather than stranding it.
*/
export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const run = useRequirementActions();
const { can } = usePermissions();
const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation();
const [draft, setDraft] = useState<Draft>(() => toDraft(licenseType));
const [dirty, setDirty] = useState(false);
// Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates
// the licence-type list, and the refetch that follows must not overwrite an
// edit the administrator is still working on.
useEffect(() => {
setDraft(toDraft(licenseType));
setDirty(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [licenseType.id]);
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
setDraft((current) => ({ ...current, [key]: value }));
setDirty(true);
}
async function onSave() {
const ok = await run(
() => save({ id: licenseType.id, ...draft }).unwrap(),
t('certReq.behavior.saved', 'Configuration saved.'),
);
if (ok) setDirty(false);
}
return (
<Stack gap="md">
<Section title={t('certReq.behavior.workflow', 'Workflow')}>
<Alert
variant="light"
color="yellow"
icon={<IconAlertTriangle size={16} />}
>
<Text size="sm">
{t(
'certReq.behavior.workflowWarning',
'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.',
)}
</Text>
</Alert>
<Select
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
description={t(
'certReq.behavior.workflowProfileHint',
'REGISTRATION drops the evaluation and inspection stages.',
)}
data={[
{ value: 'STANDARD', label: t('certReq.behavior.standard', 'Standard licence course') },
{ value: 'REGISTRATION', label: t('certReq.behavior.registration', 'Registration (review only)') },
]}
value={draft.workflowProfile}
onChange={(v) => v && set('workflowProfile', v as WorkflowProfile)}
allowDeselect={false}
disabled={!canEdit}
/>
<Select
label={t('certReq.behavior.completionEffect', 'Completion effect')}
description={t(
'certReq.behavior.completionEffectHint',
'Platform action taken when an application of this type completes.',
)}
data={[
{ value: 'REGISTER_SEAFARER', label: t('certReq.behavior.registerSeafarer', 'Register the seafarer') },
{ value: 'REGISTER_VESSEL', label: t('certReq.behavior.registerVessel', 'Register the vessel') },
{ value: 'OPEN_SEAFARER_DOCUMENTS', label: t('certReq.behavior.openDocuments', 'Open seafarer documents') },
]}
value={draft.completionEffect}
onChange={(v) => set('completionEffect', (v as CompletionEffect) ?? null)}
placeholder={t('certReq.behavior.noEffect', 'No side effect')}
clearable
disabled={!canEdit}
/>
<Switch
checked={draft.requiresExamination}
onChange={(e) => set('requiresExamination', e.currentTarget.checked)}
label={t('certReq.behavior.requiresExamination', 'Requires an examination')}
description={t(
'certReq.behavior.requiresExaminationHint',
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.issuesCertificate}
onChange={(e) => set('issuesCertificate', e.currentTarget.checked)}
label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')}
description={t(
'certReq.behavior.issuesCertificateHint',
'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.',
)}
disabled={!canEdit}
/>
<Select
label={t('certReq.behavior.serviceKind', 'Service kind')}
description={t(
'certReq.behavior.serviceKindHint',
'Catalogue classification only — no workflow depends on it.',
)}
data={[
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
]}
value={draft.serviceKind}
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
allowDeselect={false}
disabled={!canEdit}
/>
</Section>
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
<Text size="xs" c="dimmed">
{t(
'certReq.behavior.eligibilityHint',
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
)}
</Text>
<NullableNumber
label={t('certReq.behavior.capitalThreshold', 'Minimum paid-up capital')}
switchLabel={t('certReq.behavior.capitalOn', 'Require a minimum paid-up capital')}
description={t(
'certReq.behavior.capitalHint',
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
)}
value={draft.capitalThreshold}
onChange={(v) => set('capitalThreshold', v)}
disabled={!canEdit}
min={0}
defaultValue={1_000_000}
thousandSeparator
/>
<Switch
checked={draft.requiresSeafarerRegistration}
onChange={(e) => set('requiresSeafarerRegistration', e.currentTarget.checked)}
label={t('certReq.behavior.requiresSeafarer', 'Requires an active seafarer registration')}
description={t(
'certReq.behavior.requiresSeafarerHint',
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.requiresValidMedical}
onChange={(e) => set('requiresValidMedical', e.currentTarget.checked)}
label={t('certReq.behavior.requiresMedical', 'Requires a current medical certificate')}
disabled={!canEdit}
/>
<NullableNumber
label={t('certReq.behavior.minSeaTime', 'Minimum sea time (days)')}
switchLabel={t('certReq.behavior.minSeaTimeOn', 'Require verified sea time')}
value={draft.minSeaTimeDays}
onChange={(v) => set('minSeaTimeDays', v)}
disabled={!canEdit}
min={0}
/>
<Select
label={t('certReq.behavior.certificateCategory', 'Certificate category')}
description={t(
'certReq.behavior.certificateCategoryHint',
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
)}
data={['COC', 'COP', 'ENDORSEMENT', 'GOC', 'NATIONAL'].map((v) => ({
value: v,
label: v,
}))}
value={draft.certificateCategory}
onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)}
placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')}
clearable
disabled={!canEdit}
/>
</Section>
<Section title={t('certReq.behavior.renewal', 'Validity and renewal')}>
{/* Amount + unit rather than a years box that silently divides by 12:
the seed says `validityMonths: 12` and this now says "12 Months",
so the two read the same. Months advance the calendar (issued on
the 31st, expires on the 31st); days are for terms shorter than a
month can express. */}
<Group align="flex-end" gap="sm" wrap="nowrap">
<NumberInput
label={t('certReq.behavior.validity', 'Valid for')}
description={t(
'certReq.behavior.validityHint',
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
)}
value={draft.validityDays ?? draft.validityMonths}
onChange={(v) => {
const next = typeof v === 'number' ? v : 0;
if (!next) return;
if (draft.validityDays !== null) set('validityDays', next);
else set('validityMonths', next);
}}
// Matches the server's ranges, so the box cannot offer a value the
// save would reject: 13650 days, or 6240 months.
min={draft.validityDays !== null ? 1 : 6}
max={draft.validityDays !== null ? 3650 : 240}
allowNegative={false}
disabled={!canEdit}
flex={1}
/>
<Select
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
data={[
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
]}
value={draft.validityDays !== null ? 'DAYS' : 'MONTHS'}
onChange={(unit) => {
// Switching unit is a change of policy, not a conversion: 12
// calendar months is not 365 days, so carry no arithmetic across
// and let the administrator state the new term outright.
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
else set('validityDays', null);
}}
allowDeselect={false}
disabled={!canEdit}
w={130}
/>
</Group>
<Switch
checked={draft.renewalEnabled}
onChange={(e) => set('renewalEnabled', e.currentTarget.checked)}
label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
description={t(
'certReq.behavior.renewalEnabledHint',
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
)}
disabled={!canEdit}
/>
{/* The window and the reminders are both measured against an expiry a
non-renewing licence never reaches, so they are hidden rather than
shown as settings that quietly do nothing. */}
{draft.renewalEnabled && (
<>
<NumberInput
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
value={draft.renewalWindowDays}
onChange={(v) =>
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
}
min={1}
max={365}
allowNegative={false}
disabled={!canEdit}
/>
<MultiSelect
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
description={t(
'certReq.behavior.remindersHint',
'The holder is reminded at each of these offsets.',
)}
data={REMINDER_OFFSETS}
value={draft.expiryReminderDays.map(String)}
onChange={(values) =>
set(
'expiryReminderDays',
values.map(Number).sort((a, b) => b - a),
)
}
disabled={!canEdit}
clearable
/>
</>
)}
</Section>
<Section title={t('certReq.behavior.applicantRules', 'Applicant rules')}>
<Switch
checked={draft.requiresOperatorMode}
onChange={(e) => set('requiresOperatorMode', e.currentTarget.checked)}
label={t('certReq.behavior.requiresOperatorMode', 'Applicant must declare this operating mode')}
description={t(
'certReq.behavior.requiresOperatorModeHint',
'Turn off for person-centric registrations any signed-in applicant may start.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.allowMultipleOpenDrafts}
onChange={(e) => set('allowMultipleOpenDrafts', e.currentTarget.checked)}
label={t('certReq.behavior.allowMultipleDrafts', 'Allow several open drafts at once')}
description={t(
'certReq.behavior.allowMultipleDraftsHint',
'On for per-asset registrations — registering a second vessel must not resume the first ones draft.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.inspectionRequired}
onChange={(e) => set('inspectionRequired', e.currentTarget.checked)}
label={t('certReq.behavior.inspectionRequired', 'Requires a physical inspection')}
description={t(
'certReq.behavior.inspectionRequiredHint',
'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.',
)}
disabled={!canEdit}
/>
<Switch
checked={draft.requiresIssuanceScheduling}
onChange={(e) => set('requiresIssuanceScheduling', e.currentTarget.checked)}
label={t('certReq.behavior.requiresScheduling', 'Schedule a pickup date before issuing')}
description={t(
'certReq.behavior.requiresSchedulingHint',
'For documents printed once and handed over in person.',
)}
disabled={!canEdit}
/>
<NullableNumber
label={t('certReq.behavior.slaHours', 'Decision target (hours)')}
switchLabel={t('certReq.behavior.slaOn', 'Track against an SLA')}
value={draft.slaHours}
onChange={(v) => set('slaHours', v)}
disabled={!canEdit}
min={1}
description={t(
'certReq.behavior.slaHint',
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
)}
/>
<TextInput
label={t('certReq.behavior.uniqueFormKey', 'One application per answer at')}
description={t(
'certReq.behavior.uniqueFormKeyHint',
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this types form, or no application can be submitted.',
)}
value={draft.uniqueFormKeyPath ?? ''}
onChange={(e) =>
set('uniqueFormKeyPath', e.currentTarget.value.trim() || null)
}
maxLength={128}
placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')}
disabled={!canEdit}
/>
</Section>
<Group justify="flex-end">
<Tooltip
label={t('certReq.behavior.noPermission', 'You do not have permission to change licence configuration.')}
disabled={canEdit}
>
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={onSave}
loading={saving}
disabled={!canEdit || !dirty}
>
{t('certReq.behavior.save', 'Save configuration')}
</Button>
</Tooltip>
</Group>
</Stack>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Text fw={600} size="sm">
{title}
</Text>
{children}
</Stack>
</Paper>
);
}
/**
* A number that may be switched off entirely.
*
* Null is a real configuration state here — "not tracked against an SLA", "no
* sea-time floor" — and is not the same as an empty box, so the choice gets its
* own switch rather than being inferred from a blank field.
*/
function NullableNumber({
label,
switchLabel,
description,
value,
onChange,
disabled,
min,
defaultValue,
thousandSeparator,
}: {
label: string;
switchLabel: string;
description?: string;
value: number | null;
onChange: (value: number | null) => void;
disabled: boolean;
min: number;
/** Seeded when the switch is turned on. Defaults to the minimum. */
defaultValue?: number;
thousandSeparator?: boolean;
}) {
return (
<Stack gap="xs">
<Switch
checked={value !== null}
onChange={(e) =>
onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null)
}
label={switchLabel}
description={description}
disabled={disabled}
/>
{value !== null && (
<NumberInput
label={label}
value={value}
onChange={(v) => onChange(typeof v === 'number' ? v : value)}
min={min}
allowNegative={false}
thousandSeparator={thousandSeparator ? ',' : undefined}
decimalScale={thousandSeparator ? 2 : undefined}
disabled={disabled}
/>
)}
</Stack>
);
}

View File

@@ -1,45 +1,118 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Divider,
Drawer,
MultiSelect,
NumberInput,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api';
import {
useLocalized,
type ApplicationKind,
type DocumentRequirement,
type FormSchemaPalette,
type LicenseType,
} from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
/**
* File types a slot may be opened to.
*
* Longer than the three a slot starts with, because what an applicant
* actually has is not always a scan: a phone photographs an ID as HEIC, a
* scanner writes multi-page TIFF, and an academic record often arrives as the
* Word file its institution issued. Widening a slot stays a deliberate choice
* — the defaults below do not change — but it no longer needs a release.
*/
const MIME_OPTIONS = [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' },
{ value: 'image/webp', label: 'WebP' },
{ value: 'image/heic', label: 'HEIC (iPhone photo)' },
{ value: 'image/heif', label: 'HEIF' },
{ value: 'image/tiff', label: 'TIFF (scan)' },
{ value: 'image/bmp', label: 'BMP' },
{ value: 'application/msword', label: 'Word (.doc)' },
{
value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
label: 'Word (.docx)',
},
{ value: 'application/vnd.ms-excel', label: 'Excel (.xls)' },
{
value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
label: 'Excel (.xlsx)',
},
{ value: 'text/csv', label: 'CSV' },
{ value: 'text/plain', label: 'Plain text (.txt)' },
// Video is measured in hundreds of megabytes, not the 5 MB a slot starts
// with: raise "Max file size" on any slot that accepts one.
{ value: 'video/mp4', label: 'Video (.mp4)' },
{ value: 'video/quicktime', label: 'Video (.mov, iPhone)' },
{ value: 'video/webm', label: 'Video (.webm)' },
{ value: 'video/x-msvideo', label: 'Video (.avi)' },
{ value: 'audio/mpeg', label: 'Audio (.mp3)' },
{ value: 'audio/wav', label: 'Audio (.wav)' },
// Both, because the same .m4a is reported as audio/mp4 by Chrome and
// audio/x-m4a by Safari; picking one would reject half the recordings.
{ value: 'audio/mp4', label: 'Audio (.m4a)' },
{ value: 'audio/x-m4a', label: 'Audio (.m4a, Safari)' },
{ value: 'audio/ogg', label: 'Audio (.ogg)' },
];
/** What a new slot accepts until someone widens it. */
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
function emptyDraft(applicationKind: ApplicationKind): DraftRequirement {
/**
* Which licences a personal document is asked for.
*
* Empty means every licence (stored as a row with no licence type); otherwise
* one row per chosen type, all sharing the key. The applicant sees one slot
* either way — the portal collapses the rows by key — and only if they have
* declared operating as one of the types.
*/
export type PersonalScope = string[];
function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement {
return {
key: '',
name: { en: '', am: '' },
applicationKind,
mode: 'ALWAYS',
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
// 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: [...DEFAULT_MIME_TYPES],
maxSizeMb: 5,
requiresValidityDates: false,
allowMultiple: false,
isPersonal: personal,
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,19 +122,33 @@ export function DocumentRequirementEditorDrawer({
palette,
conditionTargets,
saving,
personal = false,
licenseTypes = [],
scope = [],
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new requirement. */
requirement: DocumentRequirement | null;
defaultApplicationKind: ApplicationKind;
onSave: (draft: DraftRequirement) => void;
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
palette: FormSchemaPalette | undefined;
conditionTargets: ConditionTarget[];
saving: boolean;
/** Editing a personal document — one kept in the applicant's own vault. */
personal?: boolean;
/** Licence types offered as scope; only read when `personal`. */
licenseTypes?: LicenseType[];
/** The licence types this document is already scoped to. */
scope?: PersonalScope;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<DraftRequirement>(emptyDraft(defaultApplicationKind));
const localized = useLocalized();
const [draft, setDraft] = useState<DraftRequirement>(
emptyDraft(defaultApplicationKind, personal),
);
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !requirement;
@@ -80,13 +167,19 @@ export function DocumentRequirementEditorDrawer({
maxSizeMb: requirement.maxSizeMb,
requiresValidityDates: requirement.requiresValidityDates,
allowMultiple: requirement.allowMultiple,
isPersonal: requirement.isPersonal ?? personal,
maxFiles: requirement.maxFiles ?? null,
sortOrder: requirement.sortOrder,
}
: emptyDraft(defaultApplicationKind),
: emptyDraft(defaultApplicationKind, personal),
);
setScopeIds(scope);
setAppliesToAll(scope.length === 0);
setKeyError(null);
}
}, [opened, requirement, defaultApplicationKind]);
// `scope` is a fresh array each render; the opened flag is what gates this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, requirement, defaultApplicationKind, personal]);
function save() {
if (!draft.key.trim()) {
@@ -107,11 +200,22 @@ export function DocumentRequirementEditorDrawer({
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}
onSave({
...draft,
key: draft.key.trim(),
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
});
if (personal && !appliesToAll && scopeIds.length === 0) {
setKeyError(t('certReq.doc.scopeRequired', 'Choose at least one licence type'));
return;
}
onSave(
{
...draft,
key: draft.key.trim(),
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
isPersonal: personal,
// `allowMultiple` predates `maxFiles` and nothing reads it any more;
// kept in step so the two columns never contradict each other.
allowMultiple: draft.maxFiles !== 1,
},
appliesToAll ? [] : scopeIds,
);
}
return (
@@ -123,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"
@@ -130,8 +248,23 @@ 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')}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
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.
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, key: value }));
}}
/>
<BilingualInput
@@ -147,32 +280,72 @@ 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 && (
<Stack gap="xs">
<Text fz="sm" fw={500}>
{t('certReq.doc.scope', 'Applies to')}
</Text>
<SegmentedControl
fullWidth
value={appliesToAll ? 'all' : 'selected'}
onChange={(v) => setAppliesToAll(v === 'all')}
data={[
{ value: 'all', label: t('certReq.doc.scopeAll', 'All licences') },
{
value: 'selected',
label: t('certReq.doc.scopeSelected', 'Selected licence types'),
},
]}
/>
{!appliesToAll && (
<MultiSelect
data={licenseTypes
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key }))}
value={scopeIds}
onChange={setScopeIds}
searchable
placeholder={t('certReq.doc.scopePlaceholder', 'Choose licence types')}
description={t(
'certReq.doc.scopeHelp',
'Only applicants who declared one of these as a mode of operation are asked for it.',
)}
/>
)}
</Stack>
)}
<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.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}
/>
)}
{draft.mode === 'CONDITIONAL' && (
{!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}
/>
)}
{!personal && draft.mode === 'CONDITIONAL' && (
<>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder
@@ -187,6 +360,11 @@ export function DocumentRequirementEditorDrawer({
<MultiSelect
label={t('certReq.doc.allowedTypes', 'Allowed file types')}
description={t(
'certReq.doc.allowedTypesHelp',
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
)}
searchable
data={MIME_OPTIONS}
value={draft.allowedMimeTypes}
onChange={(v) => setDraft((d) => ({ ...d, allowedMimeTypes: v }))}
@@ -199,16 +377,29 @@ 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) => {
const { checked } = e.currentTarget;
setDraft((d) => ({ ...d, requiresValidityDates: 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

@@ -50,7 +50,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
const requirements = useMemo(
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
// Personal documents can also name a licence type — they are asked for in
// the applicant's vault, not on this form, and are edited in Configuration.
() =>
(data?.items ?? []).filter(
(r) => r.licenseTypeId === licenseType.id && !r.isPersonal,
),
[data, licenseType.id],
);
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);

View File

@@ -113,7 +113,10 @@ export function FieldEditorDrawer({
? t('certReq.field.keyHelp', 'Letters, numbers and underscores only — becomes the form data key')
: t('certReq.field.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, key: value }));
}}
/>
<BilingualInput
@@ -134,7 +137,10 @@ export function FieldEditorDrawer({
<Checkbox
label={t('certReq.field.required', 'Required')}
checked={Boolean(draft.required)}
onChange={(e) => setDraft((d) => ({ ...d, required: e.currentTarget.checked }))}
onChange={(e) => {
const { checked } = e.currentTarget;
setDraft((d) => ({ ...d, required: checked }));
}}
/>
<BilingualInput

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,502 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
AdvancedTable,
ModalFooter,
useServerTable,
type AdvancedColumn,
} from '@ema-platform/ui';
import {
useCreateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetLicenseTypesQuery,
useGetPersonalDocumentsQuery,
useLocalized,
useUpdateDocumentRequirementMutation,
type DocumentRequirement,
type PersonalDocumentGroup as PersonalDocumentGroupDto,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
import {
DocumentRequirementEditorDrawer,
type PersonalScope,
} from './DocumentRequirementEditorDrawer';
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
/** Filter value for "the documents every licence asks for". */
const GLOBAL_ONLY = 'GLOBAL';
const SEARCH_DEBOUNCE_MS = 300;
/**
* Badge colours for licence types.
*
* Red is left out: it reads as a problem, and a licence type is not one.
* Everything else the theme offers is in, because the point of the colour is
* telling two licence types apart at a glance.
*/
const SCOPE_COLORS = [
'blue',
'grape',
'teal',
'orange',
'violet',
'cyan',
'pink',
'lime',
'indigo',
'green',
'yellow',
'gray',
];
/** Doubles the palette: the same hue, a visibly different badge. */
const SCOPE_VARIANTS = ['light', 'outline'] as const;
/**
* A colour per licence type, assigned by position in the catalogue.
*
* Hashing the id looked tidier and was wrong: eight buckets over sixteen
* licence types collide by the pigeonhole principle, so Vessel Registration
* and Freight Forwarder came out the same colour and the badge stopped
* carrying information. Walking the sorted catalogue instead gives every type
* a distinct colour until the palette runs out, and only then repeats a hue in
* the other variant — 24 distinct badges before any two can look alike.
*
* Sorted by `sortOrder` so the assignment is the same for every officer and
* survives a refresh; a type added later takes the next free style rather than
* reshuffling the ones already learned.
*/
function buildScopeStyles(
types: { id: string; sortOrder: number }[],
): Map<string, { color: string; variant: (typeof SCOPE_VARIANTS)[number] }> {
const styles = new Map<
string,
{ color: string; variant: (typeof SCOPE_VARIANTS)[number] }
>();
types
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.forEach((type, index) => {
styles.set(type.id, {
color: SCOPE_COLORS[index % SCOPE_COLORS.length],
variant:
SCOPE_VARIANTS[
Math.floor(index / SCOPE_COLORS.length) % SCOPE_VARIANTS.length
],
});
});
return styles;
}
/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */
const MIME_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'application/msword': 'DOC',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
'application/vnd.ms-excel': 'XLS',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
};
function shortMime(mime: string): string {
return MIME_LABELS[mime] ?? mime.split('/')[1]?.toUpperCase() ?? mime;
}
/**
* A document as the table renders it: what the server sent, plus the row id
* `AdvancedTable` keys on and the scope read off its rows.
*
* The grouping itself belongs to the server — a page of rows would split a
* document configured for three licence types across two pages and misreport
* the scope of both halves.
*/
interface PersonalDocumentGroup extends PersonalDocumentGroupDto {
/** The key doubles as the row id; one group is one document. */
id: string;
/** Empty when the document applies to every licence. */
scope: PersonalScope;
}
/**
* Documents an applicant keeps in their own vault.
*
* Same `document_requirements` table as a licence type's upload slots, flagged
* `isPersonal`: these are not asked for on an application form but held once,
* under My Documents in the portal. A document can apply to every licence or
* only to the modes of operation an applicant has declared — a sea service
* book is worth asking a seafarer for and pointless for a freight forwarder.
*/
export function PersonalDocumentsCard() {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<PersonalDocumentGroup | null>(null);
/** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */
const [licenseTypeFilter, setLicenseTypeFilter] = useState<string | null>(null);
const { pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({
pageSize: 10,
});
const [searchInput, setSearchInput] = useState('');
// Typing must not fire a request per keystroke; same 300ms as the queue.
const [search] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Every facet goes to the server: it filters and searches in SQL, groups the
// rows into documents, then pages the documents.
const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({
search: search.trim() || undefined,
licenseTypeId:
licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY
? licenseTypeFilter
: undefined,
globalOnly: licenseTypeFilter === GLOBAL_ONLY,
take: pageSize,
skip: pageIndex * pageSize,
locale: i18n.language === 'am' ? 'am' : 'en',
});
const groups = useMemo<PersonalDocumentGroup[]>(
() =>
(data?.items ?? []).map((group) => ({
...group,
id: group.key,
// A single row with no licence type means "every licence"; the two
// never coexist, because the editor writes one shape or the other.
scope: group.rows
.map((r) => r.licenseTypeId)
.filter((id): id is string => id !== null),
})),
[data],
);
/** Filters are the server's business now; an empty page is its answer. */
const isFiltered = search.trim() !== '' || licenseTypeFilter !== null;
function clearFilters() {
setSearchInput('');
setLicenseTypeFilter(null);
setPageIndex(0);
}
const scopeStyles = useMemo(
() => buildScopeStyles(licenseTypes?.items ?? []),
[licenseTypes],
);
const typeName = (id: string) => {
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
return found ? localized(found.name) || found.key : id;
};
const columns = useMemo<AdvancedColumn<PersonalDocumentGroup>[]>(
() => [
{
header: t('certReq.personal.columns.document', 'Document'),
label: t('certReq.personal.columns.document', 'Document'),
cell: ({ row }) => (
<div>
<Text fz="sm" fw={600}>
{localized(row.original.rows[0].name) || row.original.key}
</Text>
<Text fz="xs" c="dimmed">
{row.original.key}
</Text>
</div>
),
},
{
header: t('certReq.doc.scope', 'Applies to'),
label: t('certReq.doc.scope', 'Applies to'),
cell: ({ row }) =>
row.original.scope.length === 0 ? (
// Filled, where a licence type is outlined: "every licence" is a
// different kind of answer, not one more item in the same list.
<Badge size="sm" variant="filled" color="gray">
{t('certReq.doc.scopeAll', 'All licences')}
</Badge>
) : (
<Group gap={4}>
{row.original.scope.map((id) => {
// A type the catalogue no longer lists still needs a badge.
const style = scopeStyles.get(id) ?? { color: 'gray', variant: 'light' };
return (
<Badge key={id} size="sm" variant={style.variant} color={style.color}>
{typeName(id)}
</Badge>
);
})}
</Group>
),
},
{
header: t('certReq.doc.maxFiles', 'Files accepted'),
label: t('certReq.doc.maxFiles', 'Files accepted'),
align: 'center',
cell: ({ row }) =>
row.original.rows[0].maxFiles === null
? t('certReq.doc.maxFilesUnlimited', 'No limit')
: row.original.rows[0].maxFiles,
},
{
header: t('certReq.doc.allowedTypes', 'Allowed file types'),
label: t('certReq.doc.allowedTypes', 'Allowed file types'),
cell: ({ row }) => {
const types = row.original.rows[0].allowedMimeTypes ?? [];
return (
// Twenty-odd mime types would own the row; the full list is one
// hover away instead.
<Tooltip label={types.join(', ')} multiline w={280} disabled={types.length <= 3}>
<Text fz="xs">
{types.slice(0, 3).map(shortMime).join(', ')}
{types.length > 3
? t('certReq.personal.moreTypes', ' +{{count}} more', {
count: types.length - 3,
})
: ''}
</Text>
</Tooltip>
);
},
},
{
header: t('certReq.doc.maxSize', 'Max file size (MB)'),
label: t('certReq.doc.maxSize', 'Max file size (MB)'),
align: 'center',
cell: ({ row }) => `${row.original.rows[0].maxSizeMb} MB`,
},
{
header: '',
label: t('certReq.personal.columns.actions', 'Actions'),
align: 'right',
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => setEditing({ group: row.original })}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => setDeleteTarget(row.original)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
),
},
],
// `typeName` and `scopeStyles` both close over the licence-type list.
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, localized, licenseTypes, scopeStyles],
);
/**
* Saves the group as the set of rows it now means.
*
* The scope is edited as a whole, so the diff is the honest way to apply it:
* rows for licence types that were added get created, rows for types that
* were dropped get deleted, and everything still in scope is updated. A
* document moved to "all licences" collapses to a single row with none.
*/
async function handleSave(draft: DraftRequirement, scope: PersonalScope) {
const existing = editing?.group?.rows ?? [];
// `null` is a licence type here too — the one meaning "every licence".
const wanted: (string | null)[] = scope.length ? scope : [null];
const ok = await run(async () => {
const stale = existing.filter((row) => !wanted.includes(row.licenseTypeId));
const kept = existing.filter((row) => wanted.includes(row.licenseTypeId));
const added = wanted.filter(
(id) => !existing.some((row) => row.licenseTypeId === id),
);
await Promise.all([
...kept.map((row) => updateRequirement({ id: row.id, ...draft }).unwrap()),
...added.map((licenseTypeId) =>
createRequirement({ ...draft, licenseTypeId }).unwrap(),
),
...stale.map((row) => deleteRequirement(row.id).unwrap()),
]);
}, editing?.group
? 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(
() => Promise.all(deleteTarget.rows.map((row) => deleteRequirement(row.id).unwrap())),
t('certReq.doc.deleted', 'Document requirement removed'),
);
if (ok) setDeleteTarget(null);
}
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Title order={5}>{t('certReq.personal.title', 'Personal documents')}</Title>
<Text fz="sm" c="dimmed">
{t(
'certReq.personal.subtitle',
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
)}
</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditing({ group: null })}
style={{ flexShrink: 0 }}
>
{t('certReq.personal.add', 'Add personal document')}
</Button>
</Group>
{/* Facets, in the shape the licence-review queue uses. No date range:
a configuration row has no submission date to filter on. */}
<Paper withBorder p="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('certReq.personal.search', 'Search')}
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
leftSection={<IconSearch size={14} />}
value={searchInput}
onChange={(e) => {
const { value } = e.currentTarget;
setSearchInput(value);
setPageIndex(0);
}}
w={240}
/>
<Select
label={t('certReq.doc.scope', 'Applies to')}
placeholder={t('certReq.personal.filterAny', 'Any licence type')}
data={[
{
value: GLOBAL_ONLY,
label: t('certReq.personal.filterGlobal', 'All-licence documents only'),
},
...(licenseTypes?.items ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key })),
]}
value={licenseTypeFilter}
onChange={(value) => {
setLicenseTypeFilter(value);
// A narrower list can be shorter than the page you were on.
setPageIndex(0);
}}
searchable
clearable
w={240}
/>
{isFiltered && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={clearFilters}
>
{t('certReq.personal.clearFilters', 'Clear')}
</Button>
)}
</Group>
</Paper>
<AdvancedTable
tableName={t('certReq.personal.title', 'Personal documents')}
columns={columns}
data={groups}
itemCount={data?.total ?? 0}
pageIndex={pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={
isFiltered
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
: t('certReq.personal.empty', 'No personal documents configured yet.')
}
/>
<DocumentRequirementEditorDrawer
opened={editing !== null}
onClose={() => setEditing(null)}
requirement={editing?.group?.rows[0] ?? null}
defaultApplicationKind="NEW"
onSave={handleSave}
palette={undefined}
conditionTargets={[]}
saving={creating || updating}
personal
licenseTypes={licenseTypes?.items ?? []}
scope={editing?.group?.scope ?? []}
/>
<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.rows[0].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>
</Stack>
);
}

View File

@@ -72,7 +72,10 @@ export function SectionEditorDrawer({
? t('certReq.section.keyHelp', 'Letters, numbers and underscores only')
: t('certReq.section.keyLocked', 'Key cannot change once created')
}
onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, key: value }));
}}
/>
<BilingualInput
@@ -95,7 +98,10 @@ export function SectionEditorDrawer({
'Sections sharing the same group render together on one step',
)}
value={draft.group ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, group: value || undefined }));
}}
/>
<NumberInput

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

@@ -1,9 +1,16 @@
import { useEffect, useState } from 'react';
import { Container, Select, Stack, Tabs } from '@mantine/core';
import { IconAlertCircle, IconFileText, IconFiles, IconSettings } from '@tabler/icons-react';
import {
IconAdjustments,
IconAlertCircle,
IconFileText,
IconFiles,
IconSettings,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ErrorState, PageHeader, PageLoader } from '@ema-platform/ui';
import { extractErrorMessage, useGetLicenseTypesQuery, useLocalized } from '@ema-platform/api';
import { BehaviorTab } from '../components/BehaviorTab';
import { DocumentRequirementsTab } from '../components/DocumentRequirementsTab';
import { FormSchemaTab } from '../components/FormSchemaTab';
@@ -78,6 +85,9 @@ export function CertificateRequirementsPage() {
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>
{t('certReq.tabDocuments', 'Document requirements')}
</Tabs.Tab>
<Tabs.Tab value="behavior" leftSection={<IconAdjustments size={15} />}>
{t('certReq.tabBehavior', 'Behaviour')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="schema" pt="md">
@@ -87,6 +97,10 @@ export function CertificateRequirementsPage() {
<Tabs.Panel value="documents" pt="md">
<DocumentRequirementsTab licenseType={selectedType} />
</Tabs.Panel>
<Tabs.Panel value="behavior" pt="md">
<BehaviorTab licenseType={selectedType} />
</Tabs.Panel>
</Tabs>
)}
</Stack>

View File

@@ -24,6 +24,7 @@ import {
IconHash,
IconInfoCircle,
IconAnchor,
IconId,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
@@ -35,6 +36,9 @@ import {
PageLoader,
} from "@ema-platform/ui";
import { LocationPage } from "../../../location/pages/LocationPage";
// Lives with the document-requirement editor it reuses; shown here because a
// document required for every licence is configuration, not one type's form.
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
@@ -406,6 +410,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="ranks" leftSection={<IconAnchor size={16} />}>
{t("configuration.ranksTab", "Ranks & Departments")}
</Tabs.Tab>
<Tabs.Tab value="personalDocuments" leftSection={<IconId size={16} />}>
{t("configuration.personalDocumentsTab", "Personal Documents")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -427,6 +434,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="ranks" pt="md">
<RankDepartmentTab />
</Tabs.Panel>
<Tabs.Panel value="personalDocuments" pt="md">
<PersonalDocumentsCard />
</Tabs.Panel>
</Tabs>
</Stack>
);

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

@@ -179,9 +179,25 @@ function FeeEditModal({
// are real configuration states, not blank fields.
const [chargeable, setChargeable] = useState(true);
const [sameAsNew, setSameAsNew] = useState(true);
// The examined-certificate stages. Held the same way — null means "this stage
// is not charged", which is different from a blank box.
const [eligibilityFee, setEligibilityFee] = useState<number | ''>('');
const [examinationFee, setExaminationFee] = useState<number | ''>('');
const [certificateFee, setCertificateFee] = useState<number | ''>('');
const examined = licenseType?.requiresExamination ?? false;
useEffect(() => {
if (!licenseType) return;
setEligibilityFee(
licenseType.feeEligibility == null ? '' : Number(licenseType.feeEligibility),
);
setExaminationFee(
licenseType.feeExamination == null ? '' : Number(licenseType.feeExamination),
);
setCertificateFee(
licenseType.feeCertificate == null ? '' : Number(licenseType.feeCertificate),
);
setChargeable(licenseType.feeNewApplication !== null);
setNewFee(
licenseType.feeNewApplication === null
@@ -221,6 +237,14 @@ function FeeEditModal({
feeNewApplication: chargeable ? Number(newFee) : null,
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
feeCurrency: currency.trim() || 'ETB',
// Only sent for examined types; otherwise the columns stay untouched.
...(examined
? {
feeEligibility: eligibilityFee === '' ? null : Number(eligibilityFee),
feeExamination: examinationFee === '' ? null : Number(examinationFee),
feeCertificate: certificateFee === '' ? null : Number(certificateFee),
}
: {}),
}).unwrap();
notify.success(
t('paymentConfig.modal.updated', {
@@ -325,6 +349,68 @@ function FeeEditModal({
</>
)}
{examined && (
<>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
<Text size="sm">
{t(
'paymentConfig.modal.examinedNotice',
'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.',
)}
</Text>
</Alert>
<NumberInput
label={t('paymentConfig.modal.eligibilityFeeLabel', 'Eligibility assessment fee')}
description={t(
'paymentConfig.modal.eligibilityFeeHint',
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
)}
value={eligibilityFee}
onChange={(v) => setEligibilityFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
<NumberInput
label={t('paymentConfig.modal.examinationFeeLabel', 'Examination fee')}
description={t(
'paymentConfig.modal.examinationFeeHint',
'Due once eligibility is approved, and again for a retake.',
)}
value={examinationFee}
onChange={(v) => setExaminationFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
<NumberInput
label={t('paymentConfig.modal.certificateFeeLabel', 'Certificate fee')}
description={t(
'paymentConfig.modal.certificateFeeHint',
'Due after a pass, before the certificate is issued.',
)}
value={certificateFee}
onChange={(v) => setCertificateFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
</>
)}
<ModalFooter mt="xs">
<Button variant="default" onClick={onClose} disabled={isLoading}>
{t('paymentConfig.modal.cancel', 'Cancel')}

View File

@@ -852,6 +852,7 @@ export const am: Translations = {
configuration: {
title: "ውቅረት",
personalDocumentsTab: "የግል ሰነዶች",
departments: "ክፍሎች",
professions: "ሙያዎች",
departmentsList: "ክፍሎች",
@@ -1147,6 +1148,7 @@ export const am: Translations = {
uploaded: "{{document}} ተጭኗል",
},
documents: {
fromVault: "ከሰነዶቼ",
completeness: "የሚያስፈልጉ ሰነዶች",
accepted: "ተቀባይነት አግኝቷል",
rejected: "ተቀባይነት አላገኘም",
@@ -1244,12 +1246,14 @@ export const am: Translations = {
designer: {
title: "የምስክር ወረቀት ንድፍ",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።",
subtitle: "ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ።",
licenceType: "የፈቃድ ዓይነት",
validityYears: "የሚቆይበት (ዓመታት)",
validityHint: "ፈቃድ ሲሰጥ ተግባራዊ ይሆናል",
saveValidity: "የሚቆይበትን ጊዜ አስቀምጥ",
validitySaved: "የሚቆይበት ጊዜ ተዘምኗል",
validity: "የሚቆይበት",
validityMonths_one: "{{count}} ወር",
validityMonths_other: "{{count}} ወራት",
validityDays_one: "{{count}} ቀን",
validityDays_other: "{{count}} ቀናት",
validityEditedOn: "— በምስክር ወረቀት መስፈርቶች → ባህሪ ውስጥ ይዘጋጃል",
newVersion: "አዲስ ስሪት",
versions: "ስሪቶች",
name: "የስሪት ስም",
@@ -1292,6 +1296,86 @@ export const am: Translations = {
loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም",
tabSchema: "የቅጽ ቅንብር",
tabDocuments: "የሰነድ መስፈርቶች",
tabBehavior: "ባህሪ",
behavior: {
workflow: "የሥራ ሂደት",
workflowWarning:
"እነዚህ ሦስት ቅንብሮች ማመልከቻው የሚከተለውን ሂደት ይወስናሉ። የዚህ ዓይነት ማመልከቻዎች ውሳኔ በመጠባበቅ ላይ እያሉ ሊቀየሩ አይችሉም — እነሱ ውሳኔ እስኪያገኙ ድረስ ማስቀመጡ ተቀባይነት አያገኝም።",
workflowProfile: "የሥራ ሂደት ዓይነት",
workflowProfileHint: "ምዝገባ የግምገማና የምርመራ ደረጃዎችን ያስቀራል።",
standard: "መደበኛ የፈቃድ ሂደት",
registration: "ምዝገባ (ግምገማ ብቻ)",
completionEffect: "የማጠናቀቂያ ውጤት",
completionEffectHint:
"የዚህ ዓይነት ማመልከቻ ሲጠናቀቅ ሥርዓቱ የሚወስደው እርምጃ።",
registerSeafarer: "መርከበኛውን መዝግብ",
registerVessel: "መርከቧን መዝግብ",
openDocuments: "የመርከበኛ ሰነዶችን ክፈት",
noEffect: "ምንም ውጤት የለም",
requiresExamination: "ፈተና ያስፈልገዋል",
requiresExaminationHint:
"ማመልከቻውን በብቁነት፣ ከዚያ በፈተና፣ ከዚያ በምስክር ወረቀት ሂደት ያሳልፋል። ሦስቱን የደረጃ ክፍያዎች በክፍያ ቅንብር ገጽ ላይ ያስቀምጡ።",
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
issuesCertificateHint:
"በኢማ ውሳኔ ተጠናቆ ወደ ክፍያ ደረጃ ለማይደርስ ዓይነት ያጥፉ። እንደገና ማብራት የአዲስ ማመልከቻ ክፍያ እንዲዘጋጅ ይጠይቃል፤ አለበለዚያ የጸደቁ አመልካቾች የሌለ ክፍያ እንዲከፍሉ ይጠየቃሉ።",
inspectionRequired: "አካላዊ ምርመራ ያስፈልገዋል",
inspectionRequiredHint:
"ምርመራ ተመዝግቦ እስኪያልፍ ድረስ ኃላፊው ማጽደቅ አይችልም። ማመልከቻዎች በሂደት ላይ እያሉ መቀየር አስተማማኝ ነው — ኃላፊው በግምገማ ላይ ላለ ማመልከቻ አሁንም መርማሪ መመደብ ይችላል።",
renewalEnabled: "ባለቤቶች ይህንን ፈቃድ ማደስ ይችላሉ",
renewalEnabledHint:
"አንድ ጊዜ ብቻ ለሚሰጡ ያጥፉ — ጊዜው ለማያልፍ ምዝገባ፣ ወይም ለአንድ ጭነት ለተጻፈ ነፃ ፈቃድ።",
serviceKind: "የአገልግሎት ዓይነት",
serviceKindHint: "ለዝርዝር ምድብ ብቻ — ምንም የሥራ ሂደት በእሱ ላይ አይመሠረትም።",
license: "ፈቃድ",
registrationKind: "ምዝገባ",
eligibility: "የብቁነት መስፈርቶች",
eligibilityHint:
"አመልካቹ ሲያስገባ ይመረመራሉ፤ የካፒታል መስፈርቱ ደግሞ ኃላፊው ሲያጸድቅ እንደገና ይመረመራል። መስፈርትን ማጥበቅ ቀደም ብሎ ረቂቅ የጀመረን ሰው ከማስገባት ሊያግደው ይችላል።",
capitalThreshold: "አነስተኛ የተከፈለ ካፒታል",
capitalOn: "አነስተኛ የተከፈለ ካፒታል ይጠየቅ",
capitalHint:
"ኃላፊው ከዚህ እኩል ወይም በላይ የሆነ ካፒታል እስኪያረጋግጥ ድረስ ማጽደቅ አይችልም። ለአዲሶቹ ብቻ ሳይሆን ቀደም ብለው በወረፋ ላይ ላሉ ማመልከቻዎችም ይሠራል።",
validity: "የሚቆይበት",
validityUnit: "የሚቆይበት መለኪያ",
unitMonths: "ወራት",
unitDays: "ቀናት",
validityHint:
"ፈቃድ ሲሰጥ ተግባራዊ ይሆናል። ቀደም ብለው የተሰጡ ፈቃዶች የተሰጣቸውን የማብቂያ ቀን ይይዛሉ።",
requiresSeafarer: "የጸና የመርከበኛ ምዝገባ ያስፈልገዋል",
requiresSeafarerHint:
"ይህንን ዓይነት እንደ የመርከበኛ የምስክር ወረቀት ያመለክታል፤ በመርከበኛው ፖርታል ላይ እንዲታይ የሚያደርገው ይኸው ነው።",
requiresMedical: "የጸና የሕክምና ማረጋገጫ ያስፈልገዋል",
minSeaTime: "አነስተኛ የባህር አገልግሎት (ቀናት)",
minSeaTimeOn: "የተረጋገጠ የባህር አገልግሎት ይጠየቅ",
certificateCategory: "የምስክር ወረቀት ምድብ",
certificateCategoryHint:
"ይህ የሚሰጠው የሰነድ ዓይነት። ማረጋገጫዎች በመርከበኛው ፖርታል ላይ ለብቻቸው ይመደባሉ።",
notACertificate: "የምስክር ወረቀት አይደለም",
renewal: "የሚቆይበት ጊዜና እድሳት",
renewalWindow: "እድሳት የሚከፈትበት (ጊዜው ከማብቃቱ በፊት ያሉ ቀናት)",
reminders: "የማብቂያ አስታዋሾች (ቀደም ብለው ያሉ ቀናት)",
remindersHint: "ባለቤቱ በእያንዳንዱ በእነዚህ ጊዜያት ይታሰባል።",
applicantRules: "የአመልካች ደንቦች",
requiresOperatorMode: "አመልካቹ ይህንን የሥራ ዘርፍ ማሳወቅ አለበት",
requiresOperatorModeHint:
"ማንኛውም የገባ አመልካች ሊጀምራቸው ለሚችሉ በሰው ላይ ለሚያተኩሩ ምዝገባዎች ያጥፉ።",
allowMultipleDrafts: "በአንድ ጊዜ ብዙ ክፍት ረቂቆችን ፍቀድ",
allowMultipleDraftsHint:
"ለእያንዳንዱ ንብረት ለሚደረጉ ምዝገባዎች ያብሩ — ሁለተኛ መርከብ መመዝገብ የመጀመሪያዋን ረቂቅ መቀጠል የለበትም።",
requiresScheduling: "ከመስጠት በፊት የመረከቢያ ቀን ይያዝ",
requiresSchedulingHint: "አንድ ጊዜ ታትመው በአካል ለሚሰጡ ሰነዶች።",
slaHours: "የውሳኔ ጊዜ ግብ (ሰዓት)",
slaOn: "በጊዜ ገደብ ይከታተል",
slaHint:
"የዕድሜ/የጊዜ ገደብ አምድንና የዘገዩ እይታን ይመራል። ቀደም ብለው ለቀረቡ ማመልከቻዎችም እንደ አዲሶቹ ይሠራል።",
uniqueFormKey: "በዚህ መልስ አንድ ማመልከቻ ብቻ",
uniqueFormKeyHint:
"በነጥብ የተለየ የቅጽ መንገድ፣ ለምሳሌ shipment.billOfLading። መስኩ በዚህ ዓይነት ቅጽ ውስጥ መኖር አለበት፤ አለበለዚያ ምንም ማመልከቻ ማስገባት አይቻልም።",
noUniqueRule: "የልዩነት ደንብ የለም",
save: "ቅንብሩን አስቀምጥ",
saved: "ቅንብሩ ተቀምጧል።",
noPermission: "የፈቃድ ቅንብርን ለመቀየር ፈቃድ የለዎትም።",
},
cancel: "ይቅር",
delete: "ሰርዝ",
saveChanges: "ለውጦችን አስቀምጥ",
@@ -1377,6 +1461,11 @@ export const am: Translations = {
emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።",
key: "ቁልፍ",
keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ",
keyHelpPersonal:
"ይህ ሰነድ ሊመልሰው ከሚገባው የፈቃድ ሰነድ ጋር አንድ አይነት ቁልፍ ይጠቀሙ — ሁለቱ ቁልፎች በትክክል ሲመሳሰሉ ብቻ ማመልከቻው ከዚህ ሰነድ ይሞላል።",
keyMatchTitle: "ከፈቃድ ጋር የሚያገናኘው ቁልፍ ነው",
keyMatchBody:
"ይህን ሰነድ የያዘ አመልካች፣ ተመሳሳይ ቁልፍ ያለው መስፈርት ለሚጠይቅ ማንኛውም የፈቃድ ማመልከቻ ሰነዱ ይገለበጥለታል። ከምንም ጋር የማይመሳሰል ቁልፍ አሁንም ይሰበሰባል — በራሱ ብቻ ቅጽ አይሞላም።",
keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም",
keyRequired: "ቁልፍ ያስፈልጋል",
name: "ስም",
@@ -1391,13 +1480,46 @@ export const am: Translations = {
modeOptional: "አማራጭ ስቀላ",
conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል",
allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች",
allowedTypesHelp:
"አመልካቹ እነዚህን ብቻ መስቀል ይችላል። በነባሪ PDF፣ JPEG እና PNG ተመርጠዋል።",
maxSize: "ከፍተኛ የፋይል መጠን (MB)",
requiresValidity: "የቀን ገደብ ያስፈልጋል",
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
multiple: "ብዙ",
scope: "የሚመለከተው",
scopeAll: "ሁሉም ፈቃዶች",
scopeSelected: "የተመረጡ የፈቃድ አይነቶች",
scopePlaceholder: "የፈቃድ አይነቶችን ይምረጡ",
scopeHelp: "ከእነዚህ አንዱን የሥራ ዘርፍ አድርገው ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
scopeRequired: "ቢያንስ አንድ የፈቃድ አይነት ይምረጡ",
maxFiles: "የሚፈቀዱ ፋይሎች",
maxFilesHelp: "ገደብ ከሌለ ባዶ ይተዉት። ፊትና ጀርባ ላለው ሰነድ 2 ይጠቀሙ።",
maxFilesUnlimited: "ገደብ የለም",
sortOrder: "የቅደም ተከተል ቁጥር",
when: "መቼ",
},
personal: {
title: "የግል ሰነዶች",
subtitle:
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
add: "የግል ሰነድ ጨምር",
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
search: "ፍለጋ",
searchPlaceholder: "በስም ወይም በቁልፍ ይፈልጉ",
moreTypes: " +{{count}} ተጨማሪ",
columns: {
document: "ሰነድ",
actions: "ድርጊቶች",
},
filterAny: "ማንኛውም የፈቃድ አይነት",
filterGlobal: "ለሁሉም ፈቃዶች የሚሆኑ ብቻ",
noMatch: "በእነዚህ ማጣሪያዎች የሚመጣጠን የግል ሰነድ የለም።",
clearFilters: "አጽዳ",
fileCount_one: "{{count}} ፋይል",
fileCount_other: "{{count}} ፋይሎች",
deleteWarning:
"ማስገቢያው ከሁሉም አመልካቾች \u2018ሰነዶቼ\u2019 ውስጥ ይጠፋል። ቀደም ብለው የተሰቀሉ ፋይሎች ይቀመጣሉ፣ ነገር ግን ማንም ሊደርስባቸው አይችልም።",
},
},
seafarerRegistry: {
@@ -1502,6 +1624,15 @@ export const am: Translations = {
newFeeLabel: "የአዲስ ማመልከቻ ክፍያ",
sameRateLabel: "እድሳትን በተመሳሳይ ተመን ያስከፍሉ",
sameRateDescription: "የተለየ የእድሳት ክፍያ ለማዘጋጀት ያጥፉ።",
examinedNotice:
"ይህ የምስክር ወረቀት በፈተና የሚገኝ ስለሆነ በሦስት ደረጃዎች ይከፈላል። ከላይ ካሉት ክፍያዎች በተለየ እነዚህ በሚጸድቁበት ጊዜ አይቀዘቅዙም — ለውጡ ቀደም ብለው በሂደት ላይ ላሉ ተፈታኞችም ይሠራል። ተፈታኞች ሊከፍሉት በመጠባበቅ ላይ እያሉ አንዱን ማጥፋት ተቀባይነት አያገኝም።",
eligibilityFeeLabel: "የብቁነት ምዘና ክፍያ",
eligibilityFeeHint:
"ኃላፊው ማመልከቻውን ከመገምገሙ በፊት፣ በሚቀርብበት ጊዜ የሚከፈል። ክፍያ ከሌለ ባዶ ይተውት።",
examinationFeeLabel: "የፈተና ክፍያ",
examinationFeeHint: "ብቁነቱ ሲጸድቅ የሚከፈል፣ እንዲሁም ለድጋሚ ፈተና እንደገና።",
certificateFeeLabel: "የምስክር ወረቀት ክፍያ",
certificateFeeHint: "ካለፉ በኋላ፣ የምስክር ወረቀቱ ከመሰጠቱ በፊት የሚከፈል።",
renewalFeeLabel: "የእድሳት ክፍያ",
currencyLabel: "ገንዘብ",
cancel: "ሰርዝ",

View File

@@ -857,6 +857,7 @@ export const en = {
configuration: {
title: 'Configuration',
personalDocumentsTab: 'Personal Documents',
departments: 'Departments',
professions: 'Professions',
departmentsList: 'Departments',
@@ -1154,6 +1155,7 @@ export const en = {
uploaded: 'Uploaded {{document}}',
},
documents: {
fromVault: 'From My Documents',
completeness: 'Required documents',
accepted: 'Accepted',
rejected: 'Rejected',
@@ -1251,12 +1253,14 @@ export const en = {
designer: {
title: 'Certificate designer',
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
subtitle: 'Design the certificate issued to licence holders.',
licenceType: 'Licence type',
validityYears: 'Valid for (years)',
validityHint: 'Applied when a licence is issued',
saveValidity: 'Save validity',
validitySaved: 'Validity updated',
validity: 'Valid for',
validityMonths_one: '{{count}} month',
validityMonths_other: '{{count}} months',
validityDays_one: '{{count}} day',
validityDays_other: '{{count}} days',
validityEditedOn: '— set on Certificate Requirements → Behaviour',
newVersion: 'New version',
versions: 'Versions',
name: 'Version name',
@@ -1298,6 +1302,86 @@ export const en = {
loadFailed: 'Could not load licence types',
tabSchema: 'Form schema',
tabDocuments: 'Document requirements',
tabBehavior: 'Behaviour',
behavior: {
workflow: 'Workflow',
workflowWarning:
'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.',
workflowProfile: 'Workflow profile',
workflowProfileHint: 'REGISTRATION drops the evaluation and inspection stages.',
standard: 'Standard licence course',
registration: 'Registration (review only)',
completionEffect: 'Completion effect',
completionEffectHint:
'Platform action taken when an application of this type completes.',
registerSeafarer: 'Register the seafarer',
registerVessel: 'Register the vessel',
openDocuments: 'Open seafarer documents',
noEffect: 'No side effect',
requiresExamination: 'Requires an examination',
requiresExaminationHint:
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
issuesCertificate: 'Issues a certificate',
issuesCertificateHint:
'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.',
inspectionRequired: 'Requires a physical inspection',
inspectionRequiredHint:
'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.',
renewalEnabled: 'Holders may renew this licence',
renewalEnabledHint:
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
serviceKind: 'Service kind',
serviceKindHint: 'Catalogue classification only — no workflow depends on it.',
license: 'Licence',
registrationKind: 'Registration',
eligibility: 'Eligibility gates',
eligibilityHint:
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
capitalThreshold: 'Minimum paid-up capital',
capitalOn: 'Require a minimum paid-up capital',
capitalHint:
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
validity: 'Valid for',
validityUnit: 'Validity unit',
unitMonths: 'Months',
unitDays: 'Days',
validityHint:
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
requiresSeafarer: 'Requires an active seafarer registration',
requiresSeafarerHint:
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
requiresMedical: 'Requires a current medical certificate',
minSeaTime: 'Minimum sea time (days)',
minSeaTimeOn: 'Require verified sea time',
certificateCategory: 'Certificate category',
certificateCategoryHint:
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
notACertificate: 'Not a certificate',
renewal: 'Validity and renewal',
renewalWindow: 'Renewal opens (days before expiry)',
reminders: 'Expiry reminders (days before)',
remindersHint: 'The holder is reminded at each of these offsets.',
applicantRules: 'Applicant rules',
requiresOperatorMode: 'Applicant must declare this operating mode',
requiresOperatorModeHint:
'Turn off for person-centric registrations any signed-in applicant may start.',
allowMultipleDrafts: 'Allow several open drafts at once',
allowMultipleDraftsHint:
'On for per-asset registrations — registering a second vessel must not resume the first one\u2019s draft.',
requiresScheduling: 'Schedule a pickup date before issuing',
requiresSchedulingHint: 'For documents printed once and handed over in person.',
slaHours: 'Decision target (hours)',
slaOn: 'Track against an SLA',
slaHint:
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
uniqueFormKey: 'One application per answer at',
uniqueFormKeyHint:
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type\u2019s form, or no application can be submitted.',
noUniqueRule: 'No uniqueness rule',
save: 'Save configuration',
saved: 'Configuration saved.',
noPermission: 'You do not have permission to change licence configuration.',
},
cancel: 'Cancel',
delete: 'Delete',
saveChanges: 'Save changes',
@@ -1383,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',
@@ -1397,13 +1486,47 @@ export const en = {
modeOptional: 'Optional upload',
conditionRequired: 'A conditional requirement needs a condition',
allowedTypes: 'Allowed file types',
allowedTypesHelp:
'The applicant can only upload these. PDF, JPEG and PNG are selected by default.',
maxSize: 'Max file size (MB)',
requiresValidity: 'Requires validity dates',
allowMultiple: 'Allow multiple uploads',
multiple: 'multiple',
scope: 'Applies to',
scopeAll: 'All licences',
scopeSelected: 'Selected licence types',
scopePlaceholder: 'Choose licence types',
scopeHelp:
'Only applicants who declared one of these as a mode of operation are asked for it.',
scopeRequired: 'Choose at least one licence type',
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: 'Personal documents',
subtitle:
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
add: 'Add personal document',
empty: 'No personal documents configured yet.',
search: 'Search',
searchPlaceholder: 'Search by name or key',
moreTypes: ' +{{count}} more',
columns: {
document: 'Document',
actions: 'Actions',
},
filterAny: 'Any licence type',
filterGlobal: 'All-licence documents only',
noMatch: 'No personal document matches those filters.',
clearFilters: 'Clear',
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: {
@@ -1508,6 +1631,15 @@ export const en = {
newFeeLabel: 'New application fee',
sameRateLabel: 'Charge renewal at the same rate',
sameRateDescription: 'Turn off to set a separate renewal fee.',
examinedNotice:
'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.',
eligibilityFeeLabel: 'Eligibility assessment fee',
eligibilityFeeHint:
'Due on submission, before an officer reviews the application. Leave empty for no charge.',
examinationFeeLabel: 'Examination fee',
examinationFeeHint: 'Due once eligibility is approved, and again for a retake.',
certificateFeeLabel: 'Certificate fee',
certificateFeeHint: 'Due after a pass, before the certificate is issued.',
renewalFeeLabel: 'Renewal fee',
currencyLabel: 'Currency',
cancel: 'Cancel',

View File

@@ -0,0 +1,378 @@
import { useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Divider,
FileButton,
Group,
Loader,
Modal,
Progress,
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 {
replacePersonalDocumentFile,
uploadPersonalDocumentFile,
useDeletePersonalDocumentFileMutation,
useGetMyPersonalDocumentsQuery,
type AttachmentFile,
type PersonalDocumentError,
type PersonalDocumentSlot,
type PersonalDocumentUploadResult,
} from '@ema-platform/api';
import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
/**
* A refused delete comes back through RTK, which nests the server's payload
* under `data`. Uploads report theirs directly — see the XHR helper.
*/
function errorBody(err: unknown): PersonalDocumentError {
const payload = (err as { data?: { message?: unknown } })?.data?.message;
return typeof payload === 'object' && payload !== null
? (payload as PersonalDocumentError)
: { 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; mimeType?: string | null }) => void;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const { data, isLoading, refetch } = useGetMyPersonalDocumentsQuery();
const [deleteFile] = useDeletePersonalDocumentFileMutation();
const [busy, setBusy] = useState<string | null>(null);
// Percent for the upload in flight. A video is minutes of waiting, so the
// bar is the difference between waiting and reloading the page.
const [progress, setProgress] = useState<number | 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;
}
function describe(body: PersonalDocumentError): string {
return t(`documents.personal.errors.${body.message ?? 'unknown'}`, {
...body,
defaultValue: t('documents.personal.errors.unknown'),
});
}
/** Deletes, which still go through RTK and refetch themselves. */
async function run(busyKey: string, action: () => Promise<unknown>) {
setBusy(busyKey);
setError(null);
try {
await action();
} catch (err) {
setError(describe(errorBody(err)));
} finally {
setBusy(null);
clearInput(busyKey);
}
}
/**
* Uploads, which report progress and so bypass RTK — the vault is refetched
* by hand once the file has landed.
*/
async function send(
busyKey: string,
action: (onProgress: (percent: number) => void) => Promise<PersonalDocumentUploadResult>,
) {
setBusy(busyKey);
setProgress(0);
setError(null);
const result = await action(setProgress);
if (result.ok) await refetch();
else setError(describe(result.error));
setBusy(null);
setProgress(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 send(slot.key, (onProgress) =>
uploadPersonalDocumentFile({ documentKey: slot.key, file, onProgress }),
);
}
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 send(fileId, (onProgress) =>
replacePersonalDocumentFile({ fileId, file, onProgress }),
);
}
async function confirmDelete() {
if (!deleteTarget) return;
await run(deleteTarget.id, () => deleteFile(deleteTarget.id).unwrap());
setDeleteTarget(null);
}
/** The bar belongs to the card whose slot, or whose file, is uploading. */
function isThisSlot(slot: PersonalDocumentSlot, busyKey: string) {
return busyKey === slot.key || slot.files.some((f) => f.id === busyKey);
}
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,
mimeType: file.mimeType,
})
}
>
{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>
)}
{busy !== null && progress !== null && isThisSlot(slot, busy) && (
<Stack gap={2} mt="sm">
<Progress value={progress} size="sm" radius="xl" animated />
<Text fz="xs" c="dimmed" ta="right">
{t('documents.personal.uploading', { percent: progress })}
</Text>
</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,6 +1,54 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useState } from 'react';
import {
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconCertificate,
IconEye,
IconHeartbeat,
IconIdBadge2,
IconPaperclip,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useDateDisplayer } from '@ema-platform/shared';
import { FilePreviewModal, notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetAttachmentsQuery,
useGetCertificateUrlMutation,
useGetMyLicensesQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaServiceRecordsQuery,
useGetMySeafarerDocumentsQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
type SeafarerDocument,
type SeafarerRecordStatus,
} from '@ema-platform/api';
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots';
/** What the viewer needs: the link, a caption, and how to render it. */
type Preview = { url: string; title: string; mimeType?: string | null };
const RECORD_STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'blue',
VERIFIED: 'teal',
REJECTED: 'red',
};
/**
* Placeholder until this feature has a backend.
@@ -8,14 +56,403 @@ import { useTranslation } from 'react-i18next';
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
function RecordFiles({
ownerType,
ownerId,
onPreview,
}: {
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE' | 'SEAFARER_REGISTRATION';
ownerId: string;
onPreview: (preview: Preview) => void;
}) {
const { t } = useTranslation();
const { data, isLoading } = useGetAttachmentsQuery({ ownerType, ownerId });
const files = (data ?? []).flatMap((a) => a.files);
if (isLoading) return <Loader size="xs" type="oval" />;
if (files.length === 0)
return (
<Text fz="xs" c="dimmed">
{t('documents.files.none')}
</Text>
);
return (
<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={() =>
onPreview({
url: file.url as string,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{file.originalName}
</Anchor>
) : (
<Text fz="xs">{file.originalName}</Text>
)}
</Group>
))}
</Stack>
);
}
function FieldRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fz="xs" c="dimmed">
{label}
</Text>
<Text fz="xs" fw={500} ta="right">
{value}
</Text>
</Group>
);
}
/** Seaman Book / BTC — issued on their own workflow, not as licences. */
function IssuedDocumentCard({
document,
onPreview,
}: {
document: SeafarerDocument;
onPreview: (preview: Preview) => void;
}) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const [download, { isFetching }] = useLazyGetMySeafarerDocumentDownloadQuery();
const issued = document.status === 'ISSUED';
async function open() {
try {
const { url } = await download(document.id).unwrap();
onPreview({ url, title: t(`documents.kind.${document.kind}`) });
} catch (err) {
notify.error(extractErrorMessage(err), t('documents.openFailed'));
}
}
return (
<Card withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color={issued ? 'teal' : 'gray'} radius="md">
<IconCertificate size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{t(`documents.kind.${document.kind}`)}
</Text>
<Text fz="xs" c="dimmed">
{document.documentNumber ?? document.requestNumber}
</Text>
</div>
</Group>
<Badge size="sm" variant="light" color={issued ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
{t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
{document.issueDate && (
<FieldRow label={t('seaRecords.columns.issued')} value={showDate(document.issueDate)} />
)}
{document.expiryDate && (
<FieldRow label={t('seaRecords.columns.expires')} value={showDate(document.expiryDate)} />
)}
</Stack>
<Button
mt="sm"
size="xs"
variant="light"
fullWidth
leftSection={<IconEye size={14} />}
disabled={!issued}
loading={isFetching}
onClick={open}
>
{issued ? t('documents.view') : t('documents.notIssued')}
</Button>
</Card>
);
}
export function DocumentVaultPage() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const [preview, setPreview] = useState<Preview | null>(null);
const { data: licences, isLoading: loadingLicences } = useGetMyLicensesQuery();
const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery();
const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery();
const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery();
const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
async function openCertificate(licenseId: string) {
try {
const { url } = await getCertificateUrl(licenseId).unwrap();
setPreview({ url, title: t('licensing.card.downloadCertificate') });
} catch (err) {
notify.error(extractErrorMessage(err), t('documents.openFailed'));
}
}
const licenceItems = licences?.items ?? [];
const issued = [issuedDocuments?.seamanBook, issuedDocuments?.btc].filter(
(d): d is SeafarerDocument => Boolean(d),
);
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title={t('featureUnavailable.documents.title')}
description={t('featureUnavailable.documents.description')}
<Stack gap="md">
<div>
<Title order={3}>{t('documents.title')}</Title>
<Text fz="sm" c="dimmed">
{t('documents.subtitle')}
</Text>
</div>
<Tabs defaultValue="license" variant="outline" radius="md" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="license" leftSection={<IconCertificate size={16} />}>
{t('documents.tabs.license')}
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={16} />}>
{t('documents.tabs.medical')}
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
{t('documents.tabs.seaService')}
</Tabs.Tab>
<Tabs.Tab value="personal" leftSection={<IconIdBadge2 size={16} />}>
{t('documents.tabs.personal')}
</Tabs.Tab>
</Tabs.List>
{/* ── Licences and EMA-issued documents ───────────────────────── */}
<Tabs.Panel value="license">
<Stack gap="xl">
{issued.length > 0 && (
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
{t('documents.issuedTitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{issued.map((document) => (
<IssuedDocumentCard
key={document.id}
document={document}
onPreview={setPreview}
/>
))}
</SimpleGrid>
</div>
)}
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
{t('documents.licensesTitle')}
</Text>
{loadingLicences ? (
<Loader size="sm" type="oval" />
) : licenceItems.length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.licenses')}
</Text>
) : (
// Two per row, not three: licence type names run long
// ("Multimodal Transport Operator License") and a third
// column truncates them.
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{licenceItems.map((licence) => (
<LicenseCard
key={licence.id}
license={licence}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
onDownload={() => openCertificate(licence.id)}
onRenew={() => renewLicense(licence)}
/>
))}
</SimpleGrid>
)}
</div>
</Stack>
</Tabs.Panel>
{/* ── Medical certificates ────────────────────────────────────── */}
<Tabs.Panel value="medical">
{loadingMedicals ? (
<Loader size="sm" type="oval" />
) : (medicals ?? []).length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.medical')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{(medicals ?? []).map((record) => (
<Card key={record.id} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="pink" radius="md">
<IconHeartbeat size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{record.issuerName}
</Text>
{record.certificateNumber && (
<Text fz="xs" c="dimmed">
{t('seaRecords.columns.certNumber', {
number: record.certificateNumber,
})}
</Text>
)}
</div>
</Group>
<Badge
size="sm"
variant="light"
color={RECORD_STATUS_COLOR[record.status]}
style={{ flexShrink: 0 }}
>
{t(`seaRecords.columns.recordStatus.${record.status}`, {
defaultValue: record.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
<FieldRow
label={t('seaRecords.columns.issued')}
value={showDate(record.issueDate)}
/>
<FieldRow
label={t('seaRecords.columns.expires')}
value={showDate(record.expiryDate)}
/>
<FieldRow
label={t('seaRecords.columns.fitness')}
value={t(`seaRecords.columns.fitnessOptions.${record.fitnessStatus}`, {
defaultValue: record.fitnessStatus,
})}
/>
</Stack>
<Divider my="sm" />
<RecordFiles
ownerType="MEDICAL_CERTIFICATE"
ownerId={record.id}
onPreview={setPreview}
/>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
{/* ── Sea service records ─────────────────────────────────────── */}
<Tabs.Panel value="sea-service">
{loadingSeaService ? (
<Loader size="sm" type="oval" />
) : (seaService ?? []).length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.seaService')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{(seaService ?? []).map((record) => (
<Card key={record.id} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
<IconAnchor size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{record.vesselName}
</Text>
{record.imoNumber && (
<Text fz="xs" c="dimmed">
{t('seaRecords.columns.imo', { number: record.imoNumber })}
</Text>
)}
</div>
</Group>
<Badge
size="sm"
variant="light"
color={RECORD_STATUS_COLOR[record.status]}
style={{ flexShrink: 0 }}
>
{t(`seaRecords.columns.recordStatus.${record.status}`, {
defaultValue: record.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
<FieldRow label={t('seaRecords.columns.rank')} value={record.rank} />
<FieldRow
label={t('seaRecords.columns.from')}
value={showDate(record.engagementDate)}
/>
<FieldRow
label={t('seaRecords.columns.to')}
value={showDate(record.dischargeDate)}
/>
</Stack>
<Divider my="sm" />
<RecordFiles
ownerType="SEA_SERVICE_RECORD"
ownerId={record.id}
onPreview={setPreview}
/>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
{/* ── The applicant's own document vault ──────────────────────── */}
<Tabs.Panel value="personal">
{/* 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>
<FilePreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
mimeType={preview?.mimeType}
labels={{
unsupported: t('documents.preview.unsupported'),
openInNewTab: t('documents.preview.openInNewTab'),
close: t('documents.preview.close'),
}}
/>
</Container>
);

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: 'ይተኩ',
@@ -1389,17 +1390,41 @@ export const am: Translations = {
files: {
none: 'ምንም የተያያዘ ፋይል የለም።',
},
preview: {
unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።',
openInNewTab: 'በአዲስ ትር ክፈት',
close: 'ዝጋ',
},
empty: {
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
},
personal: {
description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶችም።',
startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
description: 'የማንነትና የትምህርት ሰነዶችዎ። እዚህ አንድ ጊዜ ይስቀሉ፤ በመዝገብዎ ላይ ይቆያሉ።',
empty: 'እስካሁን የተዋቀረ የግል ሰነም።',
uploaded: 'ተሰቅሏል',
missing: 'አልተሰቀለም',
fileCount: '{{count}} ከ {{max}}',
fileCountUnlimited_one: '{{count}} ፋይል',
fileCountUnlimited_other: '{{count}} ፋይሎች',
slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።',
uploading: 'በመስቀል ላይ… {{percent}}%',
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

@@ -843,6 +843,7 @@ export const en = {
documents: {
conditional: 'conditional',
uploaded: 'uploaded',
fromVault: 'from My Documents',
officerRemark: 'Officer: {{name}}',
view: 'View',
replace: 'Replace',
@@ -1394,18 +1395,43 @@ export const en = {
files: {
none: 'No files attached.',
},
preview: {
unsupported:
'This file type cannot be shown here. Open it in a new tab to download it.',
openInNewTab: 'Open in a new tab',
close: 'Close',
},
empty: {
licenses: 'No certificates or licences have been issued to you yet.',
medical: 'No medical certificates on file yet.',
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.',
uploading: 'Uploading… {{percent}}%',
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/biometric-enrollment';
export * from './lib/features/vessel';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';

View File

@@ -21,6 +21,8 @@ import type {
LicenseTypeRequirements,
OperatorType,
AssignableOfficer,
CertificateCategory,
CompletionEffect,
DocumentDecision,
DocumentReview,
EligibleExam,
@@ -37,10 +39,15 @@ import type {
RemarkTargetType,
SavedQueueView,
SchemaIssue,
ServiceKind,
StaffRoleRequirement,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
PersonalDocumentFilter,
PersonalDocumentGroup,
WorkflowProfile,
} from './licensing.types';
/**
@@ -67,6 +74,16 @@ function serialiseQueueFilter(
return params;
}
/** Sends only the facets that are set; `search=` would match nothing. */
function dropEmpty(filter: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (value === undefined || value === null || value === '' || value === false) continue;
params[key] = value;
}
return params;
}
const TAGS = [
'LicenseType',
'OperatorType',
@@ -83,6 +100,10 @@ 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',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -127,9 +148,17 @@ export const licensingApi = baseApi
method: 'PUT',
body,
}),
// The catalogue is filtered by this, so it has to refetch too.
// The catalogue is filtered by this, so it has to refetch too — and so
// is the personal document vault, which asks for the documents the
// declared modes of operation need.
invalidatesTags: (_r, error) =>
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
error
? []
: [
listTag('OperatorType'),
listTag('LicenseType'),
listTag('PersonalDocument'),
],
}),
/**
@@ -159,6 +188,12 @@ export const licensingApi = baseApi
feeNewApplication?: number | null;
feeRenewal?: number | null;
feeCurrency?: string;
// The examined-certificate stages. Unlike the two above, these are
// read live off the licence type rather than snapshotted, so the
// server refuses to clear one a candidate is currently waiting on.
feeEligibility?: number | null;
feeExamination?: number | null;
feeCertificate?: number | null;
}
>({
query: ({ id, ...body }) => ({
@@ -170,6 +205,53 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/**
* How a licence type behaves: its workflow, eligibility gates, renewal
* policy and applicant rules — everything that used to be settable only
* by editing a seed file.
*
* Three of these (`workflowProfile`, `completionEffect`,
* `requiresExamination`) decide the course an application runs, and are
* read live rather than snapshotted. The server answers 409
* `license_type_in_use` when changing one would strand applications that
* have not yet had their approval decision.
*/
updateLicenseBehavior: builder.mutation<
LicenseType,
{
id: string;
workflowProfile?: WorkflowProfile;
serviceKind?: ServiceKind;
completionEffect?: CompletionEffect | null;
certificateCategory?: CertificateCategory | null;
requiresExamination?: boolean;
inspectionRequired?: boolean;
issuesCertificate?: boolean;
renewalEnabled?: boolean;
requiresSeafarerRegistration?: boolean;
requiresValidMedical?: boolean;
minSeaTimeDays?: number | null;
validityMonths?: number;
validityDays?: number | null;
capitalThreshold?: number | null;
renewalWindowDays?: number;
expiryReminderDays?: number[];
requiresOperatorMode?: boolean;
allowMultipleOpenDrafts?: boolean;
requiresIssuanceScheduling?: boolean;
uniqueFormKeyPath?: string | null;
slaHours?: number | null;
}
>({
query: ({ id, ...body }) => ({
url: `/license-types/${id}/behavior`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/** Validity is edited beside the certificate design, not with the fees. */
updateLicenseValidity: builder.mutation<
LicenseType,
@@ -240,9 +322,30 @@ export const licensingApi = baseApi
providesTags: () => [listTag('DocumentRequirement')],
}),
/**
* Personal document slots, grouped by key and paged by the server.
*
* Its own endpoint rather than filtering `getDocumentRequirements` in the
* browser: one document can be configured against several licence types,
* so a page of rows would split a document in half and misreport its
* scope. The server groups first, then pages.
*/
getPersonalDocuments: builder.query<
Paginated<PersonalDocumentGroup>,
PersonalDocumentFilter | void
>({
query: (filter) => ({
url: '/document-requirements/personal',
params: dropEmpty(filter ?? {}),
}),
providesTags: () => [listTag('DocumentRequirement')],
}),
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')]),
@@ -265,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>({
@@ -423,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}`,
@@ -1210,13 +1379,19 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseBehaviorMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useGetPersonalDocumentsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetStaffRoleRequirementsQuery,
useCreateStaffRoleRequirementMutation,
useUpdateStaffRoleRequirementMutation,
useDeleteStaffRoleRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,
@@ -1248,6 +1423,8 @@ export const {
useResolveRemarkMutation,
useResubmitApplicationMutation,
useGetAttachmentsQuery,
useFillApplicationDocumentsFromVaultMutation,
useFillRegistrationDocumentsFromVaultMutation,
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,

View File

@@ -400,6 +400,15 @@ const ERROR_MESSAGES: Record<string, string> = {
inspection_not_passed:
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
license_type_inactive: 'This licence type is not currently accepting applications.',
// Licence-type configuration guards. Each of these refuses a change that
// would leave applications already in progress unable to move.
license_type_in_use:
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
stage_fee_in_use:
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
form_schema_missing_protected_paths:
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {

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;
}
@@ -187,6 +195,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* A term in days instead of months, for licences shorter than a month can
* express. Wins over `validityMonths` when set; null keeps calendar months.
*/
validityDays?: number | null;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.
@@ -216,11 +229,39 @@ export interface LicenseType {
// --------------------------------------------------- examined certificates
/** Approval establishes eligibility; the certificate is earned by exam. */
requiresExamination?: boolean;
/** Assessment fee, due on submission before any officer review. */
feeEligibility?: string | number | null;
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
feeExamination?: string | number | null;
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
feeCertificate?: string | number | null;
// ------------------------------------------------------- behaviour config
// Editable from the backoffice Behavior tab. Optional because the API has
// only recently begun returning them and older mocks/fixtures omit them.
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
serviceKind?: ServiceKind;
/** Platform side effect fired when an application of this type completes. */
completionEffect?: CompletionEffect | null;
/** Only ACTIVE registered seafarers may apply. */
requiresSeafarerRegistration?: boolean;
/** Submission requires a current, unexpired medical certificate. */
requiresValidMedical?: boolean;
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
minSeaTimeDays?: number | null;
/** Whether several open drafts may exist at once, for per-asset registrations. */
allowMultipleOpenDrafts?: boolean;
/** Days before expiry that renewal opens. */
renewalWindowDays?: number;
/** Days before expiry to remind the holder, most distant first. */
expiryReminderDays?: number[];
/**
* Dotted `formData` path whose answer may appear on only one live
* application of this type. Null means no such rule.
*/
uniqueFormKeyPath?: string | null;
// ---------------------------------------------------------- STCW mapping
certificateCategory?: CertificateCategory | null;
stcwControlled?: boolean;
@@ -264,7 +305,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;
@@ -275,6 +321,14 @@ export interface DocumentRequirement {
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
/**
* True for a personal document — one the applicant keeps in their own vault
* — rather than an upload slot on an application form. Orthogonal to
* `licenseTypeId`, which still says which licences it applies to.
*/
isPersonal: boolean;
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
maxFiles: number | null;
sortOrder: number;
isActive: boolean;
}
@@ -310,6 +364,7 @@ export interface StaffEvidenceRequirement {
export interface StaffRoleRequirement {
id: string;
licenseTypeId: string;
roleKey: string;
name: Bilingual;
minCount: number;
@@ -318,6 +373,7 @@ export interface StaffRoleRequirement {
minYearsExperience: number | null;
requiredEvidence: StaffEvidenceRequirement[];
sortOrder: number;
isActive: boolean;
}
export interface LicenseTypeRequirements {
@@ -387,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;
@@ -566,6 +628,15 @@ export type QueueSortField =
/** Review → evaluation → (inspection) → approval, or the short registration course. */
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
/** A permission to operate, or a registration granting a status and a number. */
export type ServiceKind = "LICENSE" | "REGISTRATION";
/** Platform side effect fired when an application reaches COMPLETED. */
export type CompletionEffect =
| "REGISTER_SEAFARER"
| "REGISTER_VESSEL"
| "OPEN_SEAFARER_DOCUMENTS";
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
unassigned: number;
@@ -815,6 +886,31 @@ export interface IssuedLicense {
certificateFileKey: string | null;
}
/**
* One personal document as the backoffice manages it: every configured row
* sharing a key, which is one slot in the applicant's vault. Several rows mean
* the document is scoped to several licence types.
*/
export interface PersonalDocumentGroup {
key: string;
rows: DocumentRequirement[];
}
export interface PersonalDocumentFilter {
/** Matches the key and the name in either locale. */
search?: string;
/** A licence type also matches the documents every licence asks for. */
licenseTypeId?: string;
/** Narrows to the documents configured against no licence type at all. */
globalOnly?: boolean;
sortBy?: 'sortOrder' | 'key' | 'name';
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
/** Which locale `sortBy: "name"` sorts on. */
locale?: 'en' | 'am';
}
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;

View File

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

View File

@@ -0,0 +1,40 @@
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.
*
* Reads and deletes live here; the two uploads do not. `fetch` — what
* `fetchBaseQuery` runs on — cannot report how much of a request body has gone
* up, so they use XHR instead (`personal-document.upload.ts`) and the page
* refetches this query when one finishes.
*/
export const personalDocumentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
query: () => ({ url: '/profiles/me/documents' }),
providesTags: () => [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,
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[];
}

View File

@@ -0,0 +1,116 @@
import { resolveTokenFromStorage } from '../../session';
import type { PersonalDocumentSlot } from './personal-document.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** What the server says when it refuses a file — see ProfileDocumentsService. */
export interface PersonalDocumentError {
message?: string;
[detail: string]: unknown;
}
export type PersonalDocumentUploadResult =
| { ok: true; slot: PersonalDocumentSlot }
| { ok: false; error: PersonalDocumentError };
/**
* Uploads one file and reports how far it has got.
*
* XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no
* upload progress event, so a request body of any size is a spinner with
* nothing behind it. That is tolerable for a 5 MB scan and not for the video a
* slot can now be opened to, where the difference between "uploading" and
* "uploading, 12%" is the difference between waiting and reloading the page.
*
* The caller refetches the vault afterwards; nothing here touches the cache.
*/
function upload(
path: string,
method: 'POST' | 'PUT',
body: FormData,
onProgress?: (percent: number) => void,
): Promise<PersonalDocumentUploadResult> {
return new Promise((resolve) => {
const request = new XMLHttpRequest();
request.open(method, `${BASE_API_URL}${path}`);
const token = resolveTokenFromStorage();
if (token) request.setRequestHeader('Authorization', `Bearer ${token}`);
request.upload.onprogress = (event) => {
// Not every browser knows the total for a streamed body; without it a
// percentage would be invented, so the caller keeps its spinner.
if (!event.lengthComputable || !onProgress) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
request.onload = () => {
const parsed = parseBody(request.responseText);
if (request.status >= 200 && request.status < 300) {
resolve({ ok: true, slot: parsed as PersonalDocumentSlot });
return;
}
resolve({ ok: false, error: toError(parsed, request.status) });
};
// A dropped connection and a cancelled request both land here; neither
// carries a server message, so the caller falls back to its own wording.
request.onerror = () => resolve({ ok: false, error: {} });
request.onabort = () => resolve({ ok: false, error: {} });
request.send(body);
});
}
function parseBody(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Nest wraps a thrown `BadRequestException({ message, ... })` as
* `{ message: { message, ... } }`, and a plain string message as
* `{ message: "slot_full" }`. Both are flattened to the object the UI
* translates by its `message` key.
*/
function toError(parsed: unknown, status: number): PersonalDocumentError {
const message = (parsed as { message?: unknown } | null)?.message;
if (typeof message === 'object' && message !== null) {
return message as PersonalDocumentError;
}
if (typeof message === 'string') return { message };
return { message: `http_${status}` };
}
/** Adds one file to a personal document slot. */
export function uploadPersonalDocumentFile(params: {
documentKey: string;
file: File;
onProgress?: (percent: number) => void;
}): Promise<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('documentKey', params.documentKey);
body.append('file', params.file, params.file.name);
return upload('/profiles/me/documents', 'POST', body, params.onProgress);
}
/** Swaps one file for another in the same slot. */
export function replacePersonalDocumentFile(params: {
fileId: string;
file: File;
onProgress?: (percent: number) => void;
}): Promise<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('file', params.file, params.file.name);
return upload(
`/profiles/me/documents/files/${params.fileId}`,
'PUT',
body,
params.onProgress,
);
}

View File

@@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput";
export * from "./lib/input/AmharicDatePicker";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/PdfPreviewModal";
export * from "./lib/feedback/FilePreviewModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";

View File

@@ -0,0 +1,169 @@
import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react';
/** How a file is shown, once its type is known. */
type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg'];
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a'];
const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml'];
/**
* What the browser can actually render, decided from the mime type where there
* is one and the URL's extension where there is not.
*
* Presigned links carry the storage key in the path, so the extension survives
* even when the caller only has a URL. `image/tiff` and `image/heic` are
* deliberately treated as images: Safari renders both, and everywhere else the
* `<img>` fails visibly rather than an iframe offering a silent download.
*/
export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind {
const mime = mimeType?.toLowerCase() ?? '';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed';
// Word, Excel and the rest: nothing renders them inline, and an iframe would
// quietly start a download instead of previewing anything.
if (mime) return 'unsupported';
const extension = extensionOf(url);
if (!extension) return 'embed';
if (IMAGE_EXTENSIONS.includes(extension)) return 'image';
if (VIDEO_EXTENSIONS.includes(extension)) return 'video';
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio';
if (EMBED_EXTENSIONS.includes(extension)) return 'embed';
return 'unsupported';
}
function extensionOf(url: string): string | null {
// Presigned URLs carry a query string; the path is the part with the name.
const path = url.split(/[?#]/)[0];
const name = path.slice(path.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
}
/**
* The one place a stored file gets opened anywhere in the app.
*
* Never `window.open` / `target="_blank"` a file that can be shown here —
* route it through this modal so the reviewer never loses their place to a new
* tab. What a slot accepts is configuration now, so this had to grow past the
* PDF it started as: a national ID arrives as a photograph, evidence arrives
* as video, and an academic record sometimes arrives as the Word file its
* institution issued. The last of those genuinely cannot be rendered by a
* browser, so it gets an honest panel and a link out rather than an iframe
* that silently downloads it.
*/
export function FilePreviewModal({
opened,
onClose,
url,
title = 'Document',
mimeType,
/** Overrides the detected kind — for a blob URL with no extension. */
kind,
labels,
}: {
opened: boolean;
onClose: () => void;
url: string;
title?: string;
mimeType?: string | null;
kind?: PreviewKind;
/** Supplied by the app so this stays out of the i18n bundles. */
labels?: { unsupported?: string; openInNewTab?: string; close?: string };
}) {
const resolved = kind ?? resolvePreviewKind(url, mimeType);
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
size="80%"
centered
trapFocus
returnFocus
styles={
resolved === 'image' || resolved === 'video'
? // A photograph on a white sheet loses its own edges; the dark mat
// is what tells the eye where the file ends.
{ body: { background: 'var(--mantine-color-dark-8)', padding: 0 } }
: undefined
}
>
{url && resolved === 'image' && (
<img
src={url}
alt={title}
style={{
display: 'block',
margin: '0 auto',
maxWidth: '100%',
maxHeight: '85vh',
objectFit: 'contain',
}}
/>
)}
{url && resolved === 'video' && (
// Controls only, no autoplay: a review screen that starts making noise
// on open is a review screen people mute and then miss the audio on.
<video
src={url}
controls
preload="metadata"
style={{ display: 'block', width: '100%', maxHeight: '85vh' }}
>
<track kind="captions" />
</video>
)}
{url && resolved === 'audio' && (
<Stack p="md">
<audio src={url} controls style={{ width: '100%' }}>
<track kind="captions" />
</audio>
</Stack>
)}
{url && resolved === 'embed' && (
<iframe
src={url}
title={title}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
)}
{url && resolved === 'unsupported' && (
<Stack align="center" gap="sm" py="xl">
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
<IconFileUnknown size={28} stroke={1.5} />
</ThemeIcon>
<Text fz="sm" c="dimmed" ta="center" maw={420}>
{labels?.unsupported ??
'This file type cannot be shown here. Open it in a new tab to download it.'}
</Text>
<Group>
<Button
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconExternalLink size={15} />}
>
{labels?.openInNewTab ?? 'Open in a new tab'}
</Button>
<Anchor component="button" type="button" fz="sm" onClick={onClose}>
{labels?.close ?? 'Close'}
</Anchor>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { Modal } from '@mantine/core';
import { FilePreviewModal } from './FilePreviewModal';
interface PdfPreviewModalProps {
opened: boolean;
@@ -8,9 +8,14 @@ interface PdfPreviewModalProps {
}
/**
* The one place a PDF gets opened anywhere in the app. Never `window.open` /
* `target="_blank"` a PDF directly — route it through this modal instead, so
* the reviewer never loses their place to a new tab.
* A PDF viewer, kept as its own name because most callers only ever open a
* PDF and say so at the call site.
*
* The rendering lives in {@link FilePreviewModal}, which also handles images,
* video and the file types no browser can show. Callers that know the mime
* type should use that directly; the ones here pass a URL alone and get the
* same iframe they always had, since a link with no `.something` on the end
* resolves to the embed view.
*/
export function PdfPreviewModal({
opened,
@@ -19,22 +24,6 @@ export function PdfPreviewModal({
title = 'Document',
}: PdfPreviewModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
size="80%"
centered
trapFocus
returnFocus
>
{url && (
<iframe
src={url}
title={title}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
)}
</Modal>
<FilePreviewModal opened={opened} onClose={onClose} url={url} title={title} />
);
}