mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 21:50:58 +00:00
@@ -6,6 +6,7 @@ import {
|
||||
Drawer,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -13,33 +14,103 @@ import {
|
||||
} from '@mantine/core';
|
||||
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 +120,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 +165,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 +198,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 (
|
||||
@@ -131,7 +233,13 @@ export function DocumentRequirementEditorDrawer({
|
||||
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 }))}
|
||||
// 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 +255,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 +335,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 +352,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 applicant’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -819,6 +819,7 @@ export const am: Translations = {
|
||||
|
||||
configuration: {
|
||||
title: "ውቅረት",
|
||||
personalDocumentsTab: "የግል ሰነዶች",
|
||||
departments: "ክፍሎች",
|
||||
professions: "ሙያዎች",
|
||||
departmentsList: "ክፍሎች",
|
||||
@@ -1351,13 +1352,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: {
|
||||
|
||||
@@ -823,6 +823,7 @@ export const en = {
|
||||
|
||||
configuration: {
|
||||
title: 'Configuration',
|
||||
personalDocumentsTab: 'Personal Documents',
|
||||
departments: 'Departments',
|
||||
professions: 'Professions',
|
||||
departmentsList: 'Departments',
|
||||
@@ -1356,13 +1357,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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1320,17 +1320,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: 'ይህ ፋይል በመዝገብዎ ላይ የለም።',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1323,18 +1323,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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
TemplateVariable,
|
||||
PersonalDocumentFilter,
|
||||
PersonalDocumentGroup,
|
||||
} from './licensing.types';
|
||||
|
||||
/**
|
||||
@@ -63,6 +65,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',
|
||||
@@ -77,6 +89,9 @@ const TAGS = [
|
||||
'DocumentRequirement',
|
||||
'Department',
|
||||
'Rank',
|
||||
// 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;
|
||||
@@ -121,9 +136,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'),
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -234,9 +257,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')]),
|
||||
@@ -1096,6 +1140,7 @@ export const {
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
|
||||
@@ -255,7 +255,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;
|
||||
@@ -266,6 +271,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;
|
||||
}
|
||||
@@ -751,6 +764,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;
|
||||
|
||||
3
libs/api/src/lib/features/personal-document/index.ts
Normal file
3
libs/api/src/lib/features/personal-document/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './personal-document.types';
|
||||
export * from './personal-document.upload';
|
||||
export * from './personal-document-api';
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user