Merge pull request #68 from Tria-plc/WorkflowChange

Workflow change
This commit is contained in:
Nati Nigussie
2026-09-07 12:08:24 +03:00
committed by GitHub
47 changed files with 5686 additions and 449 deletions

View File

@@ -129,7 +129,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
// against an expiry that never arrives, so none of them are asked for. // against an expiry that never arrives, so none of them are asked for.
const expires = draft.validityDays !== null || draft.validityMonths > 0; const expires = draft.validityDays !== null || draft.validityMonths > 0;
// The server's floor for a stated term is 6 months, so no-expiry is a state // The server's floor for a stated term is 1 month, so no-expiry is a state
// this form can hold and edit around but cannot switch a type into. Offered // this form can hold and edit around but cannot switch a type into. Offered
// only where it is already what the type is, rather than as an option whose // only where it is already what the type is, rather than as an option whose
// save would be refused. // save would be refused.
@@ -140,8 +140,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
// Only the settings this form actually asked for. The endpoint patches, so // Only the settings this form actually asked for. The endpoint patches, so
// an omitted field keeps its stored value — and the fields hidden above are // an omitted field keeps its stored value — and the fields hidden above are
// hidden precisely because the type has no such policy, which the server // hidden precisely because the type has no such policy, which the server
// stores as a zero its own validators then refuse (`validityMonths` has a // stores as a zero its own validators then refuse (`validityMonths` and
// floor of 6, `renewalWindowDays` of 1). Echoing those back is what made // `renewalWindowDays` both have a floor of 1). Echoing those back is what made
// saving an ownership transfer fail outright. // saving an ownership transfer fail outright.
const { const {
validityMonths, validityMonths,
@@ -152,7 +152,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
} = draft; } = draft;
// A type switched from "does not expire" straight to a term in days still // A type switched from "does not expire" straight to a term in days still
// carries `validityMonths: 0`, which the server's 6-month floor refuses. // carries `validityMonths: 0`, which the server's 1-month floor refuses.
// Days win over months at issuance, so the zero is simply left alone. // Days win over months at issuance, so the zero is simply left alone.
const term = expires const term = expires
? { ? {
@@ -345,8 +345,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
else set('validityMonths', next); else set('validityMonths', next);
}} }}
// Matches the server's ranges, so the box cannot offer a value the // Matches the server's ranges, so the box cannot offer a value the
// save would reject: 13650 days, or 6240 months. // save would reject: 13650 days, or 1240 months.
min={draft.validityDays !== null ? 1 : 6} min={1}
max={draft.validityDays !== null ? 3650 : 240} max={draft.validityDays !== null ? 3650 : 240}
allowNegative={false} allowNegative={false}
disabled={!canEdit} disabled={!canEdit}
@@ -388,7 +388,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
patch({ patch({
validityDays: null, validityDays: null,
validityMonths: validityMonths:
draft.validityMonths >= 6 ? draft.validityMonths : 12, draft.validityMonths >= 1 ? draft.validityMonths : 12,
}); });
// No expiry means no renewal policy: clear it here rather than // No expiry means no renewal policy: clear it here rather than
// save renewal settings that could never fire. // save renewal settings that could never fire.

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Divider, Divider,
Drawer, Drawer,
Group,
MultiSelect, MultiSelect,
NumberInput, NumberInput,
SegmentedControl, SegmentedControl,
@@ -13,10 +14,11 @@ import {
Text, Text,
TextInput, TextInput,
} from '@mantine/core'; } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react'; import { IconCheck, IconDownload, IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui'; import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import { import {
useGetPersonalDocumentsQuery,
useLocalized, useLocalized,
type ApplicationKind, type ApplicationKind,
type DocumentRequirement, type DocumentRequirement,
@@ -74,7 +76,7 @@ const MIME_OPTIONS = [
/** What a new slot accepts until someone widens it. */ /** What a new slot accepts until someone widens it. */
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>; export type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
/** /**
* Which licences a personal document is asked for. * Which licences a personal document is asked for.
@@ -117,6 +119,7 @@ export function DocumentRequirementEditorDrawer({
opened, opened,
onClose, onClose,
requirement, requirement,
initialDraft,
defaultApplicationKind, defaultApplicationKind,
onSave, onSave,
palette, palette,
@@ -130,6 +133,8 @@ export function DocumentRequirementEditorDrawer({
onClose: () => void; onClose: () => void;
/** Null = adding a new requirement. */ /** Null = adding a new requirement. */
requirement: DocumentRequirement | null; requirement: DocumentRequirement | null;
/** Optional initial prefill when adding a new requirement (e.g. from personal docs). */
initialDraft?: Partial<DraftRequirement> | null;
defaultApplicationKind: ApplicationKind; defaultApplicationKind: ApplicationKind;
onSave: (draft: DraftRequirement, scope: PersonalScope) => void; onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
palette: FormSchemaPalette | undefined; palette: FormSchemaPalette | undefined;
@@ -142,7 +147,7 @@ export function DocumentRequirementEditorDrawer({
/** The licence types this document is already scoped to. */ /** The licence types this document is already scoped to. */
scope?: PersonalScope; scope?: PersonalScope;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const localized = useLocalized(); const localized = useLocalized();
const [draft, setDraft] = useState<DraftRequirement>( const [draft, setDraft] = useState<DraftRequirement>(
emptyDraft(defaultApplicationKind, personal), emptyDraft(defaultApplicationKind, personal),
@@ -150,36 +155,84 @@ export function DocumentRequirementEditorDrawer({
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope); const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0); const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
const [keyError, setKeyError] = useState<string | null>(null); const [keyError, setKeyError] = useState<string | null>(null);
const [selectedPersonalKey, setSelectedPersonalKey] = useState<string | null>(null);
const isNew = !requirement; const isNew = !requirement;
const { data: personalDocsData } = useGetPersonalDocumentsQuery(
{ take: 100, locale: i18n.language === 'am' ? 'am' : 'en' },
{ skip: !opened || !isNew || personal },
);
const personalDocOptions = useMemo(
() =>
(personalDocsData?.items ?? []).map((item) => ({
value: item.key,
label: `${localized(item.rows[0]?.name) || item.key} (${item.key})`,
})),
[personalDocsData, localized],
);
useEffect(() => { useEffect(() => {
if (opened) { if (opened) {
setDraft( if (requirement) {
requirement setDraft({
? { key: requirement.key,
key: requirement.key, name: { ...requirement.name },
name: { ...requirement.name }, description: requirement.description ? { ...requirement.description } : undefined,
description: requirement.description ? { ...requirement.description } : undefined, applicationKind: requirement.applicationKind,
applicationKind: requirement.applicationKind, mode: requirement.mode,
mode: requirement.mode, conditionExpression: requirement.conditionExpression,
conditionExpression: requirement.conditionExpression, allowedMimeTypes: requirement.allowedMimeTypes,
allowedMimeTypes: requirement.allowedMimeTypes, maxSizeMb: requirement.maxSizeMb,
maxSizeMb: requirement.maxSizeMb, requiresValidityDates: requirement.requiresValidityDates,
requiresValidityDates: requirement.requiresValidityDates, allowMultiple: requirement.allowMultiple,
allowMultiple: requirement.allowMultiple, isPersonal: requirement.isPersonal ?? personal,
isPersonal: requirement.isPersonal ?? personal, maxFiles: requirement.maxFiles ?? null,
maxFiles: requirement.maxFiles ?? null, sortOrder: requirement.sortOrder,
sortOrder: requirement.sortOrder, });
} setSelectedPersonalKey(null);
: emptyDraft(defaultApplicationKind, personal), } else if (initialDraft) {
); setDraft({
...emptyDraft(defaultApplicationKind, personal),
...initialDraft,
});
setSelectedPersonalKey(initialDraft.key ?? null);
} else {
setDraft(emptyDraft(defaultApplicationKind, personal));
setSelectedPersonalKey(null);
}
setScopeIds(scope); setScopeIds(scope);
setAppliesToAll(scope.length === 0); setAppliesToAll(scope.length === 0);
setKeyError(null); setKeyError(null);
} }
// `scope` is a fresh array each render; the opened flag is what gates this. // `scope` is a fresh array each render; the opened flag is what gates this.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, requirement, defaultApplicationKind, personal]); }, [opened, requirement, initialDraft, defaultApplicationKind, personal]);
function handleSelectPersonalDoc(key: string | null) {
setSelectedPersonalKey(key);
if (!key) return;
const found = personalDocsData?.items?.find((item) => item.key === key);
if (found && found.rows[0]) {
const row = found.rows[0];
setDraft((d) => ({
...d,
key: found.key,
name: { en: row.name?.en ?? '', am: row.name?.am ?? '' },
description: row.description
? { en: row.description?.en ?? '', am: row.description?.am ?? '' }
: undefined,
allowedMimeTypes: row.allowedMimeTypes?.length
? [...row.allowedMimeTypes]
: d.allowedMimeTypes,
maxSizeMb: row.maxSizeMb ?? d.maxSizeMb,
maxFiles: row.maxFiles ?? null,
requiresValidityDates: row.requiresValidityDates ?? false,
allowMultiple: (row.maxFiles ?? 1) !== 1,
}));
setKeyError(null);
}
}
function save() { function save() {
if (!draft.key.trim()) { if (!draft.key.trim()) {
@@ -241,6 +294,40 @@ export function DocumentRequirementEditorDrawer({
</Alert> </Alert>
)} )}
{!personal && isNew && (
<Stack gap="xs">
<Select
label={t('certReq.doc.importFromPersonal', 'Import from personal document')}
placeholder={t(
'certReq.doc.selectPersonalDoc',
'Select a personal document to copy details…',
)}
data={personalDocOptions}
value={selectedPersonalKey}
onChange={handleSelectPersonalDoc}
searchable
clearable
leftSection={<IconDownload size={15} />}
description={t(
'certReq.doc.importPersonalDesc',
'Select documents configured in Personal Documents to add as requirements for this licence type.',
)}
/>
{selectedPersonalKey && (
<Alert variant="light" color="teal" icon={<IconCheck size={16} />}>
<Text fz="xs">
{t(
'certReq.doc.importedHelper',
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
)}
</Text>
</Alert>
)}
<Divider my="xs" />
</Stack>
)}
<TextInput <TextInput
label={t('certReq.doc.key', 'Key')} label={t('certReq.doc.key', 'Key')}
placeholder="bank_letter" placeholder="bank_letter"

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core'; import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react'; import { IconAlertCircle, IconDownload, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui'; import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
import { import {
@@ -18,7 +18,11 @@ import {
import { collectConditionTargets } from '../config/schema-paths'; import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions'; import { useRequirementActions } from '../hooks/useRequirementActions';
import { describeCondition } from './ConditionBuilder'; import { describeCondition } from './ConditionBuilder';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer'; import {
DocumentRequirementEditorDrawer,
type DraftRequirement,
} from './DocumentRequirementEditorDrawer';
import { ImportPersonalDocumentsModal } from './ImportPersonalDocumentsModal';
/** Every kind the server validates a document set for, so each can be configured. */ /** Every kind the server validates a document set for, so each can be configured. */
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE']; const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
@@ -54,6 +58,8 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
const [deleteRequirement] = useDeleteDocumentRequirementMutation(); const [deleteRequirement] = useDeleteDocumentRequirementMutation();
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null); const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
const [initialDraft, setInitialDraft] = useState<Partial<DraftRequirement> | null>(null);
const [importModalKind, setImportModalKind] = useState<ApplicationKind | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null); const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
const requirements = useMemo( const requirements = useMemo(
@@ -120,14 +126,27 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
<Card key={kind} withBorder radius="md" p="md"> <Card key={kind} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{t(...KIND_LABEL[kind])}</Title> <Title order={5}>{t(...KIND_LABEL[kind])}</Title>
<Button <Group gap="xs">
size="xs" <Button
variant="light" size="xs"
leftSection={<IconPlus size={13} />} variant="outline"
onClick={() => setEditorState({ kind, requirement: null })} leftSection={<IconDownload size={13} />}
> onClick={() => setImportModalKind(kind)}
{t('certReq.doc.add', 'Add document requirement')} >
</Button> {t('certReq.doc.importPersonal', 'Import from personal documents')}
</Button>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => {
setInitialDraft(null);
setEditorState({ kind, requirement: null });
}}
>
{t('certReq.doc.add', 'Add document requirement')}
</Button>
</Group>
</Group> </Group>
{rows.length === 0 ? ( {rows.length === 0 ? (
@@ -180,8 +199,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
<DocumentRequirementEditorDrawer <DocumentRequirementEditorDrawer
opened={editorState !== null} opened={editorState !== null}
onClose={() => setEditorState(null)} onClose={() => {
setEditorState(null);
setInitialDraft(null);
}}
requirement={editorState?.requirement ?? null} requirement={editorState?.requirement ?? null}
initialDraft={initialDraft}
defaultApplicationKind={editorState?.kind ?? 'NEW'} defaultApplicationKind={editorState?.kind ?? 'NEW'}
onSave={handleSave} onSave={handleSave}
palette={palette} palette={palette}
@@ -189,6 +212,28 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
saving={creating || updating} saving={creating || updating}
/> />
{importModalKind && (
<ImportPersonalDocumentsModal
opened={importModalKind !== null}
onClose={() => setImportModalKind(null)}
licenseType={licenseType}
applicationKind={importModalKind}
existingKeys={requirements
.filter((r) => r.applicationKind === importModalKind)
.map((r) => r.key)}
currentMaxSortOrder={requirements
.filter((r) => r.applicationKind === importModalKind)
.reduce((max, r) => Math.max(max, r.sortOrder ?? 0), 0)}
onCustomizeAndAdd={(draft) => {
setInitialDraft(draft);
setEditorState({
kind: (draft.applicationKind as ApplicationKind) ?? importModalKind,
requirement: null,
});
}}
/>
)}
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm"> <Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
<Stack gap="md"> <Stack gap="md">
<Alert color="yellow" variant="light"> <Alert color="yellow" variant="light">

View File

@@ -0,0 +1,431 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Checkbox,
Group,
Loader,
Modal,
ScrollArea,
SegmentedControl,
Select,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import {
IconAdjustments,
IconAlertCircle,
IconCheck,
IconDownload,
IconInfoCircle,
IconSearch,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import {
useCreateDocumentRequirementMutation,
useGetLicenseTypesQuery,
useGetPersonalDocumentsQuery,
useLocalized,
type ApplicationKind,
type DocumentRequirement,
type LicenseType,
type PersonalDocumentGroup,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
import type { DraftRequirement } from './DocumentRequirementEditorDrawer';
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;
}
interface ImportPersonalDocumentsModalProps {
opened: boolean;
onClose: () => void;
licenseType: LicenseType;
applicationKind: ApplicationKind;
existingKeys: string[];
currentMaxSortOrder: number;
onCustomizeAndAdd: (draft: Partial<DraftRequirement>) => void;
}
export function ImportPersonalDocumentsModal({
opened,
onClose,
licenseType,
applicationKind,
existingKeys,
currentMaxSortOrder,
onCustomizeAndAdd,
}: ImportPersonalDocumentsModalProps) {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const [search, setSearch] = useState('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'scoped'>('all');
const [importMode, setImportMode] = useState<DocumentRequirement['mode']>('ALWAYS');
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const { data: licenseTypes } = useGetLicenseTypesQuery();
const { data: personalDocsData, isLoading, isError, refetch } = useGetPersonalDocumentsQuery({
take: 100,
locale: i18n.language === 'am' ? 'am' : 'en',
});
const [createRequirement, { isLoading: isCreating }] = useCreateDocumentRequirementMutation();
const groups = useMemo(() => {
return (personalDocsData?.items ?? []).map((group) => {
const scopeIds = group.rows
.map((r) => r.licenseTypeId)
.filter((id): id is string => id !== null);
const isGlobal = scopeIds.length === 0;
const appliesToCurrentType = isGlobal || scopeIds.includes(licenseType.id);
const isAlreadyAdded = existingKeys.includes(group.key);
return {
...group,
scopeIds,
isGlobal,
appliesToCurrentType,
isAlreadyAdded,
mainRow: group.rows[0],
};
});
}, [personalDocsData, licenseType.id, existingKeys]);
const filteredGroups = useMemo(() => {
const q = search.trim().toLowerCase();
return groups.filter((item) => {
if (scopeFilter === 'scoped' && !item.appliesToCurrentType) {
return false;
}
if (!q) return true;
const nameEn = item.mainRow.name.en?.toLowerCase() ?? '';
const nameAm = item.mainRow.name.am?.toLowerCase() ?? '';
const key = item.key.toLowerCase();
return nameEn.includes(q) || nameAm.includes(q) || key.includes(q);
});
}, [groups, search, scopeFilter]);
const selectableKeys = useMemo(
() => filteredGroups.filter((g) => !g.isAlreadyAdded).map((g) => g.key),
[filteredGroups],
);
const allSelected =
selectableKeys.length > 0 && selectableKeys.every((k) => selectedKeys.includes(k));
const someSelected =
selectableKeys.some((k) => selectedKeys.includes(k)) && !allSelected;
function toggleSelectAll() {
if (allSelected) {
setSelectedKeys((prev) => prev.filter((k) => !selectableKeys.includes(k)));
} else {
setSelectedKeys((prev) => Array.from(new Set([...prev, ...selectableKeys])));
}
}
function toggleSelectOne(key: string) {
setSelectedKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}
async function handleBatchImport() {
const itemsToImport = groups.filter(
(g) => selectedKeys.includes(g.key) && !g.isAlreadyAdded,
);
if (itemsToImport.length === 0) return;
const ok = await run(
() =>
Promise.all(
itemsToImport.map((item, index) =>
createRequirement({
key: item.key,
name: item.mainRow.name,
description: item.mainRow.description,
applicationKind,
mode: importMode,
allowedMimeTypes:
item.mainRow.allowedMimeTypes ?? ['application/pdf', 'image/jpeg', 'image/png'],
maxSizeMb: item.mainRow.maxSizeMb ?? 5,
requiresValidityDates: item.mainRow.requiresValidityDates ?? false,
allowMultiple: (item.mainRow.maxFiles ?? 1) !== 1,
isPersonal: false,
maxFiles: item.mainRow.maxFiles ?? null,
sortOrder: currentMaxSortOrder + index + 1,
licenseTypeId: licenseType.id,
}).unwrap(),
),
),
t('certReq.doc.importSuccess', 'Imported {{count}} personal document(s)', {
count: itemsToImport.length,
}),
);
if (ok) {
setSelectedKeys([]);
onClose();
}
}
function handleCustomizeRow(item: (typeof groups)[number]) {
onCustomizeAndAdd({
key: item.key,
name: { ...item.mainRow.name },
description: item.mainRow.description ? { ...item.mainRow.description } : undefined,
applicationKind,
mode: importMode,
allowedMimeTypes: [...item.mainRow.allowedMimeTypes],
maxSizeMb: item.mainRow.maxSizeMb,
maxFiles: item.mainRow.maxFiles,
requiresValidityDates: item.mainRow.requiresValidityDates,
allowMultiple: (item.mainRow.maxFiles ?? 1) !== 1,
isPersonal: false,
sortOrder: currentMaxSortOrder + 1,
});
onClose();
}
return (
<Modal
opened={opened}
onClose={onClose}
title={
<div>
<Text fw={700} fz="lg">
{t('certReq.doc.importPersonal', 'Import from personal documents')}
</Text>
<Text fz="xs" c="dimmed">
{localized(licenseType.name)} · {applicationKind}
</Text>
</div>
}
size="lg"
padding="md"
>
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
<Text fz="xs">
{t(
'certReq.doc.importedHelper',
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
)}
</Text>
</Alert>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<TextInput
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="xs"
style={{ flex: 1, minWidth: 200 }}
/>
<SegmentedControl
size="xs"
value={scopeFilter}
onChange={(v) => setScopeFilter(v as 'all' | 'scoped')}
data={[
{ value: 'all', label: t('certReq.personal.filterAny', 'All personal docs') },
{
value: 'scoped',
label: t('certReq.doc.scopeSelected', 'Relevant to licence'),
},
]}
/>
</Group>
<Group justify="space-between" align="center" gap="sm">
<Select
label={t('certReq.doc.importMode', 'Requirement mode for imported documents')}
size="xs"
w={240}
data={[
{ value: 'ALWAYS', label: t('certReq.doc.modeAlways', 'Always required') },
{ value: 'OPTIONAL', label: t('certReq.doc.modeOptional', 'Optional upload') },
{
value: 'CONDITIONAL',
label: t('certReq.doc.modeConditional', 'Required when condition holds'),
},
]}
value={importMode}
onChange={(v) => v && setImportMode(v as DocumentRequirement['mode'])}
allowDeselect={false}
/>
<Text fz="xs" c="dimmed" pt="lg">
{t('certReq.personal.fileCount', '{{count}} document(s) available', {
count: selectableKeys.length,
})}
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text fz="sm" c="dimmed">
{t('certReq.doc.loading', 'Loading personal documents…')}
</Text>
</Group>
) : isError ? (
<Alert color="red" icon={<IconAlertCircle size={16} />}>
{t('certReq.doc.loadFailed', 'Could not load personal documents')}
<Button size="xs" variant="subtle" color="red" ml="sm" onClick={() => refetch()}>
{t('landing.retry', 'Retry')}
</Button>
</Alert>
) : filteredGroups.length === 0 ? (
<Card withBorder radius="sm" p="lg" ta="center">
<Text fz="sm" c="dimmed">
{search
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
: t('certReq.doc.noPersonalDocs', 'No personal documents configured in Configuration yet.')}
</Text>
</Card>
) : (
<ScrollArea.Autosize mah={380} type="auto">
<Table highlightOnHover verticalSpacing="xs" fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectAll}
disabled={selectableKeys.length === 0}
/>
</Table.Th>
<Table.Th>{t('certReq.personal.columns.document', 'Document')}</Table.Th>
<Table.Th>{t('certReq.doc.allowedTypes', 'Format & Limits')}</Table.Th>
<Table.Th>{t('certReq.doc.scope', 'Scope')}</Table.Th>
<Table.Th ta="right">{t('certReq.personal.columns.actions', 'Actions')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredGroups.map((item) => {
const isSelected = selectedKeys.includes(item.key);
return (
<Table.Tr
key={item.key}
bg={
item.isAlreadyAdded
? 'var(--mantine-color-gray-light)'
: isSelected
? 'var(--mantine-color-blue-light)'
: undefined
}
>
<Table.Td>
<Checkbox
checked={isSelected || item.isAlreadyAdded}
disabled={item.isAlreadyAdded}
onChange={() => toggleSelectOne(item.key)}
/>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<div>
<Group gap={6}>
<Text fz="sm" fw={600}>
{localized(item.mainRow.name) || item.key}
</Text>
{item.isAlreadyAdded && (
<Badge size="xs" color="gray" variant="light">
{t('certReq.doc.alreadyAdded', 'Already added')}
</Badge>
)}
</Group>
<Text fz="xs" c="dimmed">
{item.key}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Text fz="xs">
{(item.mainRow.allowedMimeTypes ?? []).slice(0, 3).map(shortMime).join(', ')}
{(item.mainRow.allowedMimeTypes ?? []).length > 3 && '...'}
</Text>
<Text fz="xs" c="dimmed">
{item.mainRow.maxSizeMb} MB ·{' '}
{item.mainRow.maxFiles === null
? t('certReq.doc.maxFilesUnlimited', 'No limit')
: `${item.mainRow.maxFiles} file(s)`}
{item.mainRow.requiresValidityDates && ' · Validity'}
</Text>
</Table.Td>
<Table.Td>
{item.isGlobal ? (
<Badge size="xs" variant="filled" color="gray">
{t('certReq.doc.scopeAll', 'All licences')}
</Badge>
) : (
<Badge size="xs" variant="light" color="blue">
{t('certReq.doc.scopeSelected', 'Selected')}
</Badge>
)}
</Table.Td>
<Table.Td ta="right">
<Tooltip label={t('certReq.doc.customizeAndAdd', 'Customize & add')}>
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => handleCustomizeRow(item)}
>
<IconAdjustments size={14} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
)}
<ModalFooter>
<Button variant="default" onClick={onClose}>
{t('certReq.cancel', 'Cancel')}
</Button>
<Button
color="teal"
leftSection={<IconDownload size={15} />}
disabled={selectedKeys.length === 0}
loading={isCreating}
onClick={handleBatchImport}
>
{t('certReq.doc.importSelected', 'Import selected ({{count}})', {
count: selectedKeys.length,
})}
</Button>
</ModalFooter>
</Stack>
</Modal>
);
}

View File

@@ -1,10 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core'; import { Stack, Button, Modal, Text, TextInput, Textarea, Select, Card, Switch } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {IconPlus} from '@tabler/icons-react'; import {IconPlus} from '@tabler/icons-react';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { useGetRanksQuery, useLocalized } from '@ema-platform/api'; import { extractErrorMessage, useGetRanksQuery, useLocalized } from '@ema-platform/api';
import { import {
useGetCertificationsQuery, useGetCertificationsQuery,
useCreateCertificationMutation, useCreateCertificationMutation,
@@ -15,6 +15,15 @@ import { type Certification } from '../../types/certification';
import { certificationColumns } from './columns'; import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions'; import { certificationActionsColumn } from './actions';
interface CertificationFormValues {
nameEn: string;
nameAm: string;
descEn: string;
descAm: string;
rankKey: string | null;
isActive: boolean;
}
function CertificationForm({ function CertificationForm({
editing, editing,
rankOptions, rankOptions,
@@ -25,7 +34,7 @@ function CertificationForm({
editing: Certification | null; editing: Certification | null;
rankOptions: { value: string; label: string }[]; rankOptions: { value: string; label: string }[];
isSubmitting: boolean; isSubmitting: boolean;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void; onSubmit: (values: CertificationFormValues, isEdit: boolean) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -34,14 +43,15 @@ function CertificationForm({
const [descEn, setDescEn] = useState(editing?.description?.en ?? ''); const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
const [descAm, setDescAm] = useState(editing?.description?.am ?? ''); const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null); const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
const [isActive, setIsActive] = useState(editing?.isActive ?? true);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!nameEn || !nameAm) { if (!nameEn.trim() || !nameAm.trim()) {
notify.error('Name fields are required'); notify.error(t('certification.validation.nameRequired', 'Both English and Amharic names are required'));
return; return;
} }
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing); onSubmit({ nameEn: nameEn.trim(), nameAm: nameAm.trim(), descEn, descAm, rankKey, isActive }, !!editing);
}; };
return ( return (
@@ -63,6 +73,18 @@ function CertificationForm({
clearable clearable
searchable searchable
/> />
{editing && (
<Switch
label={t('certification.form.isActive', 'Active')}
description={t(
'certification.form.isActiveHint',
'Inactive certifications stay on existing exams but are not offered for new ones.',
)}
checked={isActive}
onChange={(e) => setIsActive(e.currentTarget.checked)}
size="sm"
/>
)}
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button> <Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button> <Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
@@ -76,8 +98,10 @@ function CertificationForm({
export function CertificationPage() { export function CertificationPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am'; const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const localized = useLocalized(); const localized = useLocalized();
// Server refusals (`rank_not_found`, `certification_in_use`) arrive as
// codes; this maps them to the sentences the administrator can act on.
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
const { data, isFetching, isError, refetch } = useGetCertificationsQuery(); const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
const { data: rankRes } = useGetRanksQuery(); const { data: rankRes } = useGetRanksQuery();
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) })); const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
@@ -98,14 +122,14 @@ export function CertificationPage() {
setShowForm(false); setShowForm(false);
}; };
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => { const handleSubmit = async (values: CertificationFormValues, isEdit: boolean) => {
const name = { en: values.nameEn, am: values.nameAm }; const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm }; const description = { en: values.descEn, am: values.descAm };
try { try {
if (isEdit && editing) { if (isEdit && editing) {
// null clears a previously-set rank; undefined would leave it // null clears a previously-set rank; undefined would leave it
// untouched server-side, so the two are not interchangeable here. // untouched server-side, so the two are not interchangeable here.
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap(); await updateCert({ id: editing.id, name, description, rankKey: values.rankKey, isActive: values.isActive }).unwrap();
notify.success(t('certification.updated')); notify.success(t('certification.updated'));
} else { } else {
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap(); await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
@@ -113,7 +137,7 @@ export function CertificationPage() {
} }
resetForm(); resetForm();
} catch (e) { } catch (e) {
handleError(e); showError(e);
} }
}; };
@@ -125,7 +149,7 @@ export function CertificationPage() {
closeDelete(); closeDelete();
setDeleteTarget(null); setDeleteTarget(null);
} catch (e) { } catch (e) {
handleError(e); showError(e);
} }
}; };
@@ -184,7 +208,7 @@ export function CertificationPage() {
</Card> </Card>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm"> <Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text> <Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget ? localized(deleteTarget.name) : '' })}</Text>
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button> <Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button> <Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>

View File

@@ -0,0 +1,535 @@
import { useMemo, useState } from 'react';
import {
Alert,
Anchor,
Badge,
Button,
Card,
Checkbox,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
Textarea,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
AdvancedTable,
ErrorState,
ModalFooter,
notify,
PageLoader,
useServerTable,
type AdvancedColumn,
} from '@ema-platform/ui';
import {
extractErrorMessage,
useCreateLicenseTypeMutation,
useGetLicenseCategoriesQuery,
useGetLicenseTypesQuery,
useLocalized,
useUpdateLicenseStatusMutation,
useUpdateLicenseTypeMutation,
type LicenseTypeCreate,
type FamilyKind,
type LicenseCategory,
type LicenseType,
type ServiceKind,
type WorkflowProfile,
} from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission, usePermissions } from '@ema-platform/auth';
/** Upper snake case, as the server normalises and then requires. */
const KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
interface FormValues {
key: string;
nameEn: string;
nameAm: string;
descEn: string;
descAm: string;
category: LicenseCategory | '';
familyKind: FamilyKind;
serviceKind: ServiceKind;
workflowProfile: WorkflowProfile;
certificatePrefix: string;
feeNewApplication: number | '';
feeCurrency: string;
validityMonths: number;
issuesCertificate: boolean;
renewalEnabled: boolean;
inspectionRequired: boolean;
}
const INITIAL: FormValues = {
key: '',
nameEn: '',
nameAm: '',
descEn: '',
descAm: '',
category: '',
familyKind: 'LOGISTICS_LICENSE',
serviceKind: 'LICENSE',
workflowProfile: 'STANDARD',
certificatePrefix: '',
feeNewApplication: '',
feeCurrency: 'ETB',
validityMonths: 12,
issuesCertificate: true,
renewalEnabled: true,
inspectionRequired: true,
};
/**
* The catalogue of licence types, with the one thing no other screen offers:
* creating a new one, and switching one on or off.
*
* Deliberately thin. A type is created with only what it needs to exist and
* be classified; its form, document slots, fees and behaviour rules each
* have a dedicated screen, and this one links there rather than duplicating
* them. Deactivating hides the type from the portal catalogue without
* touching applications already in flight, which hold the type by id.
*/
export function LicenseTypesTab() {
const { t } = useTranslation();
const localized = useLocalized();
const { can } = usePermissions();
const { data, isLoading, isFetching, isError, error, refetch } = useGetLicenseTypesQuery();
const { data: categoriesRes } = useGetLicenseCategoriesQuery();
const [createType, { isLoading: isCreating }] = useCreateLicenseTypeMutation();
const [updateType, { isLoading: isUpdating }] = useUpdateLicenseTypeMutation();
const [updateStatus, { isLoading: isToggling }] = useUpdateLicenseStatusMutation();
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<LicenseType | null>(null);
const [pendingToggle, setPendingToggle] = useState<LicenseType | null>(null);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const types = useMemo(
() => [...(data?.items ?? [])].sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key)),
[data],
);
const categoryOptions = useMemo(
() =>
[...(categoriesRes?.items ?? [])]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((c) => ({ value: c.key, label: localized(c.name) })),
[categoriesRes, localized],
);
const categoryLabel = (key: string) => categoryOptions.find((c) => c.value === key)?.label ?? key;
const familyOptions: { value: FamilyKind; label: string }[] = [
{ value: 'LOGISTICS_LICENSE', label: t('configuration.licenseTypes.family.LOGISTICS_LICENSE', 'Logistics licence') },
{ value: 'CERTIFICATE', label: t('configuration.licenseTypes.family.CERTIFICATE', 'Seafarer certificate') },
{ value: 'DOCUMENT', label: t('configuration.licenseTypes.family.DOCUMENT', 'Identity / statutory document') },
];
const serviceOptions: { value: ServiceKind; label: string }[] = [
{ value: 'LICENSE', label: t('configuration.licenseTypes.service.LICENSE', 'Licence') },
{ value: 'REGISTRATION', label: t('configuration.licenseTypes.service.REGISTRATION', 'Registration') },
];
const workflowOptions: { value: WorkflowProfile; label: string }[] = [
{ value: 'STANDARD', label: t('configuration.licenseTypes.workflow.STANDARD', 'Standard (review → evaluation → inspection → approval)') },
{ value: 'REGISTRATION', label: t('configuration.licenseTypes.workflow.REGISTRATION', 'Registration (review → approval)') },
];
const form = useForm<FormValues>({
initialValues: INITIAL,
transformValues: (v) => ({ ...v, key: v.key.trim().toUpperCase(), certificatePrefix: v.certificatePrefix.trim() }),
validate: {
key: (v) => {
const key = v.trim().toUpperCase();
if (!key) return t('configuration.licenseTypes.validation.keyRequired', 'Key is required');
if (key.length > 64) return t('configuration.licenseTypes.validation.keyTooLong', 'Key must be at most 64 characters');
if (!KEY_PATTERN.test(key)) {
return t('configuration.licenseTypes.validation.keyFormat', 'Use letters, digits and underscores, e.g. PORT_AGENT');
}
if (types.some((lt) => lt.key === key && lt.id !== editing?.id)) {
return t('configuration.licenseTypes.validation.keyTaken', 'A licence type with this key already exists');
}
return null;
},
nameEn: (v) => (v.trim() ? null : t('configuration.validation.nameEnRequired')),
nameAm: (v) => (v.trim() ? null : t('configuration.validation.nameAmRequired')),
certificatePrefix: (v) => {
const prefix = v.trim();
if (!prefix) return t('configuration.licenseTypes.validation.prefixRequired', 'Certificate prefix is required');
if (prefix.length > 12) return t('configuration.licenseTypes.validation.prefixTooLong', 'Prefix must be at most 12 characters');
return null;
},
feeCurrency: (v) => (v.trim().length > 8 ? t('configuration.licenseTypes.validation.currencyTooLong', 'Use a short currency code') : null),
validityMonths: (v) =>
v >= 1 && v <= 240 ? null : t('configuration.licenseTypes.validation.validityRange', 'Validity must be between 1 and 240 months'),
},
});
const closeForm = () => {
form.reset();
setEditing(null);
setShowForm(false);
};
const openCreate = () => {
form.reset();
setEditing(null);
setShowForm(true);
};
const openEdit = (licenseType: LicenseType) => {
setEditing(licenseType);
form.setValues({
key: licenseType.key,
nameEn: licenseType.name?.en ?? '',
nameAm: licenseType.name?.am ?? '',
descEn: licenseType.description?.en ?? '',
descAm: licenseType.description?.am ?? '',
category: licenseType.category ?? '',
familyKind: licenseType.familyKind ?? 'LOGISTICS_LICENSE',
serviceKind: licenseType.serviceKind ?? 'LICENSE',
workflowProfile: licenseType.workflowProfile ?? 'STANDARD',
certificatePrefix: licenseType.certificatePrefix,
feeNewApplication:
licenseType.feeNewApplication === null || licenseType.feeNewApplication === undefined
? ''
: Number(licenseType.feeNewApplication),
feeCurrency: licenseType.feeCurrency ?? 'ETB',
validityMonths: licenseType.validityMonths ?? 12,
issuesCertificate: licenseType.issuesCertificate ?? true,
renewalEnabled: licenseType.renewalEnabled ?? true,
inspectionRequired: licenseType.inspectionRequired ?? true,
});
setShowForm(true);
};
const submit = form.onSubmit(async (values) => {
// Everything both paths write. `key` and `sortOrder` are deliberately not
// here: applications, licences and the portal's own routes address a type
// by its key, so renaming one in place would strand everything already
// pointing at the old name — the server refuses it too, once any
// application references the type.
const shared: Omit<LicenseTypeCreate, 'key'> = {
name: { en: values.nameEn.trim(), am: values.nameAm.trim() },
certificatePrefix: values.certificatePrefix,
familyKind: values.familyKind,
serviceKind: values.serviceKind,
workflowProfile: values.workflowProfile,
feeCurrency: values.feeCurrency.trim() || 'ETB',
validityMonths: values.validityMonths,
issuesCertificate: values.issuesCertificate,
renewalEnabled: values.renewalEnabled,
inspectionRequired: values.inspectionRequired,
};
if (values.descEn.trim() || values.descAm.trim()) {
shared.description = { en: values.descEn.trim(), am: values.descAm.trim() };
}
if (values.category) shared.category = values.category;
if (values.feeNewApplication !== '') shared.feeNewApplication = values.feeNewApplication;
try {
if (editing) {
await updateType({ id: editing.id, ...shared }).unwrap();
notify.success(t('configuration.updated'));
} else {
await createType({
...shared,
key: values.key,
// The last row by default; the seeded order is EMA's and stays put.
sortOrder: types.length,
}).unwrap();
notify.success(
t('configuration.licenseTypes.created', 'Licence type created. Configure its form, documents and fees next.'),
);
}
closeForm();
} catch (e) {
notify.error(extractErrorMessage(e, t('configuration.error')));
}
});
const confirmToggle = async () => {
if (!pendingToggle) return;
const next = !pendingToggle.isActive;
try {
await updateStatus({ id: pendingToggle.id, isActive: next }).unwrap();
notify.success(
next
? t('configuration.licenseTypes.activated', 'Licence type is now accepting applications')
: t('configuration.licenseTypes.deactivated', 'Licence type is closed to new applications'),
);
setPendingToggle(null);
} catch (e) {
notify.error(extractErrorMessage(e, t('configuration.error')));
}
};
if (isError) {
return (
<ErrorState
title={t('certReq.loadFailed', 'Could not load licence types')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
/>
);
}
if (isLoading) return <PageLoader label={t('certReq.loading', 'Loading licence types…')} height={300} />;
const canToggle = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
const columns: AdvancedColumn<LicenseType>[] = [
{ header: t('configuration.name'), cell: ({ row }) => localized(row.original.name) },
{ header: t('configuration.key', 'Key'), cell: ({ row }) => <Text ff="monospace" size="sm">{row.original.key}</Text> },
{ header: t('configuration.licenseTypes.columns.category', 'Category'), cell: ({ row }) => categoryLabel(row.original.category) },
{
header: t('configuration.licenseTypes.columns.family', 'Family'),
cell: ({ row }) => familyOptions.find((f) => f.value === row.original.familyKind)?.label ?? row.original.familyKind,
},
{ header: t('configuration.licenseTypes.columns.prefix', 'Prefix'), cell: ({ row }) => row.original.certificatePrefix },
{
header: t('configuration.licenseTypes.columns.status', 'Status'),
size: 110,
cell: ({ row }) => (
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
{row.original.isActive ? t('configuration.licenseTypes.active', 'Active') : t('configuration.licenseTypes.inactive', 'Inactive')}
</Badge>
),
},
{
header: '',
label: t('configuration.licenseTypes.columns.actions', 'Actions'),
size: 220,
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" size="xs" onClick={() => openEdit(row.original)} disabled={!canToggle}>
{t('configuration.edit', 'Edit')}
</Button>
{/*
Bound to the stored flag rather than to local state: the switch
only opens the confirmation, and it moves once the server has
accepted. Flipping first would claim the type was closed while
the request was still in the air.
*/}
<Switch
size="sm"
checked={row.original.isActive}
disabled={!canToggle || isToggling}
onChange={() => setPendingToggle(row.original)}
label={
row.original.isActive
? t('configuration.licenseTypes.deactivate', 'Deactivate')
: t('configuration.licenseTypes.activate', 'Activate')
}
aria-label={t(
'configuration.licenseTypes.toggleAria',
'Toggle whether this licence type accepts applications',
)}
/>
</Group>
),
},
];
const page = paginate(types);
return (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
<Text size="sm">
{t(
'configuration.licenseTypes.notice',
'A new type starts empty. After creating it, set up its form and document slots under Certificate Requirements, and its fees under Payment Configuration.',
)}{' '}
<Anchor component={Link} to="/certificate-requirements" size="sm">
{t('configuration.licenseTypes.goToRequirements', 'Certificate requirements')}
</Anchor>
{' · '}
<Anchor component={Link} to="/payment-config" size="sm">
{t('configuration.licenseTypes.goToFees', 'Payment configuration')}
</Anchor>
</Text>
</Alert>
<Group justify="flex-end">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_LICENSE_TYPE]} hideOnly>
<Button variant="light" size="sm" leftSection={<IconPlus size={16} />} onClick={openCreate}>
{t('configuration.licenseTypes.add', 'Add licence type')}
</Button>
</RequirePermission>
</Group>
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName="configuration-license-types"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('configuration.licenseTypes.empty', 'No licence types yet')}
/>
</Card>
<Modal
opened={showForm}
onClose={closeForm}
title={
editing
? t('configuration.licenseTypes.edit', 'Edit licence type')
: t('configuration.licenseTypes.add', 'Add licence type')
}
size="lg"
>
<form onSubmit={submit}>
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={t('configuration.key', 'Key')}
description={t('configuration.licenseTypes.keyHint', 'Stable identifier, upper snake case. Cannot change once applications exist.')}
placeholder="PORT_AGENT"
required
{...form.getInputProps('key')}
onChange={(e) => form.setFieldValue('key', e.currentTarget.value.toUpperCase())}
disabled={Boolean(editing)}
/>
<TextInput
label={t('configuration.licenseTypes.prefix', 'Certificate number prefix')}
description={t('configuration.licenseTypes.prefixHint', 'e.g. FF → FF-2026-000123')}
placeholder="PA"
required
maxLength={12}
{...form.getInputProps('certificatePrefix')}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput label={t('configuration.nameEn')} required {...form.getInputProps('nameEn')} />
<TextInput label={t('configuration.nameAm')} required {...form.getInputProps('nameAm')} />
<Textarea label={t('configuration.descEn')} autosize minRows={2} {...form.getInputProps('descEn')} />
<Textarea label={t('configuration.descAm')} autosize minRows={2} {...form.getInputProps('descAm')} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Select
label={t('configuration.licenseTypes.columns.category', 'Category')}
description={t('configuration.licenseTypes.categoryHint', 'The group the applicant browses by.')}
data={categoryOptions}
clearable
searchable
{...form.getInputProps('category')}
/>
<Select
label={t('configuration.licenseTypes.columns.family', 'Family')}
description={t('configuration.licenseTypes.familyHint', 'Decides which desk owns it and which portal catalogue lists it.')}
data={familyOptions}
allowDeselect={false}
{...form.getInputProps('familyKind')}
/>
<Select
label={t('certReq.behavior.serviceKind', 'Service kind')}
data={serviceOptions}
allowDeselect={false}
{...form.getInputProps('serviceKind')}
/>
<Select
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
data={workflowOptions}
allowDeselect={false}
{...form.getInputProps('workflowProfile')}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<NumberInput
label={t('configuration.licenseTypes.fee', 'New application fee')}
description={t('configuration.licenseTypes.feeHint', 'Leave blank for no charge.')}
min={0}
decimalScale={2}
thousandSeparator=","
{...form.getInputProps('feeNewApplication')}
/>
<TextInput label={t('configuration.licenseTypes.currency', 'Currency')} maxLength={8} {...form.getInputProps('feeCurrency')} />
<NumberInput
label={t('configuration.licenseTypes.validity', 'Validity (months)')}
min={1}
max={240}
allowDecimal={false}
required
{...form.getInputProps('validityMonths')}
/>
</SimpleGrid>
<Group gap="lg">
<Checkbox
label={t('configuration.licenseTypes.issuesCertificate', 'Issues a certificate')}
{...form.getInputProps('issuesCertificate', { type: 'checkbox' })}
/>
<Checkbox
label={t('configuration.licenseTypes.renewalEnabled', 'Renewable')}
{...form.getInputProps('renewalEnabled', { type: 'checkbox' })}
/>
<Checkbox
label={t('configuration.licenseTypes.inspectionRequired', 'Inspection required')}
{...form.getInputProps('inspectionRequired', { type: 'checkbox' })}
/>
</Group>
<ModalFooter>
<Button variant="default" size="sm" onClick={closeForm}>
{t('configuration.cancel')}
</Button>
<Button type="submit" size="sm" loading={isCreating || isUpdating}>
{editing ? t('configuration.update') : t('configuration.create')}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
<Modal
opened={pendingToggle !== null}
onClose={() => setPendingToggle(null)}
title={
pendingToggle?.isActive
? t('configuration.licenseTypes.deactivateTitle', 'Deactivate licence type')
: t('configuration.licenseTypes.activateTitle', 'Activate licence type')
}
size="sm"
>
<Text size="sm" mb="md">
{pendingToggle?.isActive
? t('configuration.licenseTypes.deactivateText', {
name: pendingToggle ? localized(pendingToggle.name) : '',
defaultValue:
'{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.',
})
: t('configuration.licenseTypes.activateText', {
name: pendingToggle ? localized(pendingToggle.name) : '',
defaultValue: '{{name}} will be offered to applicants again.',
})}
</Text>
<ModalFooter>
<Button variant="default" size="sm" onClick={() => setPendingToggle(null)}>
{t('configuration.cancel')}
</Button>
<Button size="sm" color={pendingToggle?.isActive ? 'red' : 'green'} loading={isToggling} onClick={confirmToggle}>
{pendingToggle?.isActive
? t('configuration.licenseTypes.deactivate', 'Deactivate')
: t('configuration.licenseTypes.activate', 'Activate')}
</Button>
</ModalFooter>
</Modal>
</Stack>
);
}

View File

@@ -23,6 +23,7 @@ import {
IconCertificate, IconCertificate,
IconHash, IconHash,
IconInfoCircle, IconInfoCircle,
IconLicense,
IconAnchor, IconAnchor,
IconId, IconId,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -41,6 +42,7 @@ import { LocationPage } from "../../../location/pages/LocationPage";
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard"; import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab"; import { NumberFormatTab } from "../../components/NumberFormatTab";
import { LicenseTypesTab } from "../../components/LicenseTypesTab";
import { RankDepartmentTab } from "./RankDepartmentTab"; import { RankDepartmentTab } from "./RankDepartmentTab";
import { import {
useGetOrganizationsQuery, useGetOrganizationsQuery,
@@ -398,6 +400,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}> <Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
{t("location.title")} {t("location.title")}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="licenseTypes" leftSection={<IconLicense size={16} />}>
{t("configuration.licenseTypesTab", "Licence Types")}
</Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="certifications" value="certifications"
leftSection={<IconCertificate size={16} />} leftSection={<IconCertificate size={16} />}
@@ -427,6 +432,10 @@ export function ConfigurationPage() {
<CertificationPage /> <CertificationPage />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="licenseTypes" pt="md">
<LicenseTypesTab />
</Tabs.Panel>
<Tabs.Panel value="numberFormats" pt="md"> <Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab /> <NumberFormatTab />
</Tabs.Panel> </Tabs.Panel>

View File

@@ -0,0 +1,488 @@
import type { ReactNode } from 'react';
import {
Badge,
Box,
Card,
Center,
Group,
RingProgress,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from '@mantine/core';
import {
IconAlertTriangle,
IconChartBar,
IconClock,
IconChartPie,
IconShieldCheck,
IconTrendingUp,
IconUsers,
} from '@tabler/icons-react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { AdminDashboardAnalytics } from '@ema-platform/api';
const CHART_HEIGHT = 280;
const AXIS_STYLE = { fontSize: 11, fill: 'var(--mantine-color-dimmed)' } as const;
const GRID_COLOR = 'var(--mantine-color-default-border)';
const TOOLTIP_BOX_STYLE = {
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
fontSize: 12,
} as const;
function ChartCard({
title,
subtitle,
icon: Icon,
badge,
children,
empty,
emptyText,
}: {
title: string;
subtitle?: string;
icon?: typeof IconTrendingUp;
badge?: ReactNode;
children: ReactNode;
empty?: boolean;
emptyText?: string;
}) {
return (
<Card withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{Icon && (
<ThemeIcon size={32} radius="md" variant="light" color="blue">
<Icon size={18} />
</ThemeIcon>
)}
<div>
<Text fw={600} size="sm" lh={1.3}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</div>
</Group>
{badge}
</Group>
{empty ? (
<Center h={CHART_HEIGHT}>
<Text size="sm" c="dimmed">
{emptyText ?? 'No data available for this chart yet.'}
</Text>
</Center>
) : (
<Box w="100%" h={CHART_HEIGHT}>
<ResponsiveContainer width="100%" height="100%">
{children as never}
</ResponsiveContainer>
</Box>
)}
</Card>
);
}
const CATEGORY_NAMES: Record<string, string> = {
CARGO_FREIGHT: 'Cargo & Freight',
SHIPPING_AGENCY: 'Shipping Agency',
MARITIME_PERSONNEL: 'Maritime Personnel & CoC',
VESSEL_SERVICES: 'Vessel Registry',
INVESTMENT: 'Investment Licences',
WAIVER_SERVICES: 'Waiver Services',
LOGISTICS_SERVICES: 'Maritime Logistics',
OTHER: 'Other Services',
};
const CATEGORY_COLORS = [
'#228be6',
'#12b886',
'#7950f2',
'#fd7e14',
'#fab005',
'#fa5252',
'#15aabf',
'#868e96',
];
interface DashboardChartsProps {
analytics?: AdminDashboardAnalytics | null;
periodLabel?: string;
fallbackTrend?: Array<{
month: string;
submitted: number;
approved: number;
rejected: number;
}>;
fallbackStatusDistribution?: Array<{
name: string;
value: number;
color: string;
}>;
}
export function DashboardCharts({
analytics,
periodLabel = 'Last 6 Months',
fallbackTrend,
fallbackStatusDistribution,
}: DashboardChartsProps) {
const trendData =
analytics?.monthlyTrend && analytics.monthlyTrend.length > 0
? analytics.monthlyTrend
: fallbackTrend ?? [];
const statusData =
analytics?.statusDistribution && analytics.statusDistribution.length > 0
? analytics.statusDistribution.filter((s) => s.value > 0)
: (fallbackStatusDistribution ?? []).filter((s) => s.value > 0);
const categoryData = (analytics?.categoryBreakdown ?? [])
.map((item, idx) => ({
name: CATEGORY_NAMES[item.category] ?? item.category.replace(/_/g, ' '),
count: item.count,
color: CATEGORY_COLORS[idx % CATEGORY_COLORS.length],
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const officerData = (analytics?.officerWorkload ?? []).slice(0, 8);
const slaRate = analytics?.kpis?.slaComplianceRate ?? 100;
const overdueCount = analytics?.kpis?.overdueSla ?? 0;
const withinSlaCount = analytics?.kpis?.withinSla ?? 0;
const slaToneColor =
slaRate >= 90 ? 'teal' : slaRate >= 75 ? 'yellow' : 'red';
return (
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
{/* Chart 1: Application Intake & Decisions Trend */}
<ChartCard
title="Intake & Decisions Trend"
subtitle="Trajectory of incoming applications vs approved & issued certificates"
icon={IconTrendingUp}
badge={
<Badge variant="light" color="blue" size="sm">
{periodLabel}
</Badge>
}
empty={trendData.length === 0}
>
<AreaChart
data={trendData}
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
>
<defs>
<linearGradient id="submittedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
<stop offset="95%" stopColor="#339af0" stopOpacity={0.0} />
</linearGradient>
<linearGradient id="approvedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#12b886" stopOpacity={0.4} />
<stop offset="95%" stopColor="#12b886" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
<XAxis dataKey="month" tick={AXIS_STYLE} tickLine={false} />
<YAxis tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number, name: string) => [
val,
name === 'submitted'
? 'Submitted'
: name === 'approved'
? 'Approved / Issued'
: 'Rejected',
]}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{ fontSize: 12, paddingBottom: 8 }}
/>
<Area
type="monotone"
dataKey="submitted"
name="Submitted"
stroke="#339af0"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#submittedGrad)"
/>
<Area
type="monotone"
dataKey="approved"
name="Approved"
stroke="#12b886"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#approvedGrad)"
/>
</AreaChart>
</ChartCard>
{/* Chart 2: Pipeline Distribution Donut */}
<ChartCard
title="Pipeline Status Distribution"
subtitle="Proportion of applications by workflow state"
icon={IconChartPie}
badge={
<Badge variant="light" color="indigo" size="sm">
Live Workload
</Badge>
}
empty={statusData.length === 0}
>
<PieChart margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number, name: string) => [`${val} applications`, name]}
/>
<Legend
layout="vertical"
align="right"
verticalAlign="middle"
iconType="circle"
wrapperStyle={{ fontSize: 12, lineHeight: '22px' }}
/>
<Pie
data={statusData}
cx="40%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={3}
dataKey="value"
>
{statusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</ChartCard>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
{/* Chart 3: Category Workload Breakdown */}
<ChartCard
title="Applications by License Category"
subtitle="Top operational categories handled by the Authority"
icon={IconChartBar}
empty={categoryData.length === 0}
>
<BarChart
data={categoryData}
layout="vertical"
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} horizontal={false} />
<XAxis type="number" tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
<YAxis
type="category"
dataKey="name"
width={140}
tick={AXIS_STYLE}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number) => [`${val} applications`, 'Volume']}
/>
<Bar dataKey="count" radius={[0, 6, 6, 0]} maxBarSize={22}>
{categoryData.map((entry, index) => (
<Cell key={`cat-cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ChartCard>
{/* Chart 4: Service Level Agreement (SLA) Health */}
<Card withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size={32} radius="md" variant="light" color={slaToneColor}>
<IconShieldCheck size={18} />
</ThemeIcon>
<div>
<Text fw={600} size="sm" lh={1.3}>
SLA Health & Queue Turnaround
</Text>
<Text size="xs" c="dimmed">
Turnaround compliance against published authority SLAs
</Text>
</div>
</Group>
<Badge variant="light" color={slaToneColor} size="sm">
{slaRate}% On-Time
</Badge>
</Group>
<Center py="xs">
<RingProgress
size={170}
thickness={16}
roundCaps
sections={[
{ value: slaRate, color: slaToneColor },
{ value: 100 - slaRate, color: 'gray.2' },
]}
label={
<Center>
<Stack align="center" gap={0}>
<Text fz={26} fw={800} lh={1}>
{slaRate}%
</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} mt={4}>
Compliance
</Text>
</Stack>
</Center>
}
/>
</Center>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Box
p="sm"
style={{
borderRadius: 8,
backgroundColor: 'var(--mantine-color-teal-light)',
}}
>
<Group gap="xs">
<IconClock size={16} color="var(--mantine-color-teal-filled)" />
<Text size="xs" fw={600} c="teal">
Within SLA
</Text>
</Group>
<Text fz={22} fw={800} c="teal" mt={4}>
{withinSlaCount}
</Text>
<Text size="xs" c="dimmed">
Applications on schedule
</Text>
</Box>
<Box
p="sm"
style={{
borderRadius: 8,
backgroundColor:
overdueCount > 0
? 'var(--mantine-color-red-light)'
: 'var(--mantine-color-gray-light)',
}}
>
<Group gap="xs">
<IconAlertTriangle
size={16}
color={
overdueCount > 0
? 'var(--mantine-color-red-filled)'
: 'var(--mantine-color-dimmed)'
}
/>
<Text size="xs" fw={600} c={overdueCount > 0 ? 'red' : 'dimmed'}>
SLA Breached
</Text>
</Group>
<Text fz={22} fw={800} c={overdueCount > 0 ? 'red' : 'dimmed'} mt={4}>
{overdueCount}
</Text>
<Text size="xs" c="dimmed">
Require priority action
</Text>
</Box>
</SimpleGrid>
</Card>
</SimpleGrid>
{/* Chart 5: Officer Workload Distribution */}
{officerData.length > 0 && (
<ChartCard
title="Reviewer & Queue Workload Distribution"
subtitle="Active cases held by individual officers and the unclaimed pool, segmented by SLA status"
icon={IconUsers}
badge={
<Badge variant="light" color="indigo" size="sm">
Staff Capacity
</Badge>
}
>
<BarChart
data={officerData}
layout="vertical"
margin={{ top: 5, right: 20, left: 15, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} horizontal={false} />
<XAxis type="number" tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
<YAxis
type="category"
dataKey="officerName"
width={160}
tick={AXIS_STYLE}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number, name: string) => [
`${val} applications`,
name === 'onScheduleCount' ? 'On Schedule' : 'SLA Overdue',
]}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{ fontSize: 12, paddingBottom: 6 }}
/>
<Bar
dataKey="onScheduleCount"
name="On Schedule"
stackId="workload"
fill="#228be6"
radius={[0, 0, 0, 0]}
maxBarSize={20}
/>
<Bar
dataKey="overdueCount"
name="SLA Overdue"
stackId="workload"
fill="#fa5252"
radius={[0, 4, 4, 0]}
maxBarSize={20}
/>
</BarChart>
</ChartCard>
)}
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import { Badge, Text } from '@mantine/core'; import { Badge, Group, Text } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui'; import { WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
import { import {
STATUS_COLORS, STATUS_COLORS,
STATUS_LABELS, STATUS_LABELS,
@@ -9,23 +9,55 @@ import {
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [ export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
{ {
header: 'Number', header: 'Application #',
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" fw={500}> <Text size="sm" fw={600} c="blue">
{row.original.applicationNumber} {row.original.applicationNumber}
</Text> </Text>
), ),
}, },
{ {
header: 'Company', header: 'Company / Applicant',
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>, cell: ({ row }) => (
<Text size="sm" fw={500} lineClamp={1}>
{row.original.companyName || row.original.tradeName || '—'}
</Text>
),
},
{
header: 'License Type',
cell: ({ row }) => (
<Text size="sm" c="dimmed" lineClamp={1}>
{row.original.licenseType?.name?.en ?? 'Maritime Service'}
</Text>
),
},
{
header: 'Waiting Since',
cell: ({ row }) => (
<WaitingFor
since={row.original.submittedAt ?? row.original.createdAt}
slaDays={
row.original.licenseType?.slaHours
? row.original.licenseType.slaHours / 24
: undefined
}
/>
),
}, },
{ {
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => {
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}> const status = row.original.status as LicenseStatus;
{STATUS_LABELS[row.original.status as LicenseStatus]} return (
</Badge> <Badge
), variant="light"
color={STATUS_COLORS[status] ?? 'gray'}
size="sm"
>
{STATUS_LABELS[status] ?? status}
</Badge>
);
},
}, },
]; ];

View File

@@ -1,18 +1,52 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core'; import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Grid,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCash,
IconCertificate,
IconChevronRight,
IconClock,
IconClockExclamation,
IconCreditCard, IconCreditCard,
IconDownload,
IconFileText, IconFileText,
IconFolders,
IconInbox, IconInbox,
IconRefresh,
IconShip,
IconUserCheck, IconUserCheck,
IconUsers,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { import {
STATUS_COLORS,
STATUS_LABELS,
useGetAdminDashboardAnalyticsQuery,
useGetAssignedToMeQuery, useGetAssignedToMeQuery,
useGetQueueQuery, useGetQueueQuery,
useGetVesselsQuery,
useListSeafarerDocumentsQuery, useListSeafarerDocumentsQuery,
useListSeafarerRegistrationsQuery, useListSeafarerRegistrationsQuery,
type LicenseApplication, type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { import {
AdvancedTable, AdvancedTable,
@@ -20,178 +54,797 @@ import {
PageLoader, PageLoader,
StatTile, StatTile,
WaitingFor, WaitingFor,
notify,
useServerTable, useServerTable,
} from '@ema-platform/ui'; } from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns'; import { dashboardQueueColumns } from './columns';
import { DashboardCharts } from './DashboardCharts';
/**
* Backoffice home.
*
* Every figure here is counted from a queue the officer can open, and each
* tile navigates to the list it counted — a dashboard that cannot be drilled
* into is a poster. Nothing is charted: the platform exposes queues, not time
* series, and an earlier version of this page invented both a registration
* trend and a staff-role breakdown rather than admit that.
*
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
* queues are permission-gated and an officer without them simply gets no
* count — never a broken page — so the tiles read `—` rather than `0`, which
* would be a lie.
*/
export function DashboardPage() { export function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<string | null>('unclaimed');
const [period, setPeriod] = useState<string>('6m');
// Primary platform analytics query with time window parameter
const analyticsQuery = useGetAdminDashboardAnalyticsQuery({ period });
// Review queues (for tables and fallback counts)
const queue = useGetQueueQuery(); const queue = useGetQueueQuery();
const mine = useGetAssignedToMeQuery(); const mine = useGetAssignedToMeQuery();
const table = useServerTable(); const table = useServerTable();
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 }); // Department queues
const registrations = useListSeafarerRegistrationsQuery({
status: 'SUBMITTED',
take: 1,
});
const seamanBooks = useListSeafarerDocumentsQuery({ const seamanBooks = useListSeafarerDocumentsQuery({
kind: 'SEAMAN_BOOK', kind: 'SEAMAN_BOOK',
status: 'PAYMENT_PENDING', status: 'PAYMENT_PENDING',
take: 1, take: 1,
}); });
const btcDocuments = useListSeafarerDocumentsQuery({
kind: 'BTC_BASIC_TRAINING',
status: 'PAYMENT_PENDING',
take: 1,
});
const vesselsQuery = useGetVesselsQuery({ search: '' });
if (queue.isLoading || mine.isLoading) { const analytics = analyticsQuery.data;
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
}
// Real data arrays from queries
const unclaimed = queue.data?.items ?? []; const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? []; const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress]; const allInFlight = [...unclaimed, ...inProgress];
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length; // Fallback counts if analytics is loading
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length; const fallbackNeedsApplicant = allInFlight.filter(
(a) => a.status === 'RESUBMIT_REQUIRED',
).length;
const fallbackAwaitingPayment = allInFlight.filter(
(a) => a.status === 'PAYMENT_PENDING',
).length;
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
const byAge = [...unclaimed].sort((a, b) =>
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
);
const paged = table.paginate(byAge.slice(0, 8));
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
const countOf = (q: { data?: { total: number }; isError: boolean }) => const countOf = (q: { data?: { total: number }; isError: boolean }) =>
q.isError ? undefined : q.data?.total; q.isError ? undefined : q.data?.total;
const show = (n: number | undefined) => (n === undefined ? '—' : n); const show = (n: number | undefined) => (n === undefined ? '—' : n);
return ( // KPIs merging backend analytics with client queries for instant accuracy
<Stack gap="lg"> const unclaimedCount = analytics?.kpis.unclaimedQueue ?? unclaimed.length;
<PageHeader const assignedToMeCount = analytics?.kpis.assignedToMe ?? inProgress.length;
title="Dashboard" const inProgressTotalCount =
subtitle="Work waiting across the Authority's review queues." analytics?.kpis.inProgressTotal ?? inProgress.length;
noMargin const needsApplicantCount =
/> analytics?.kpis.needsApplicant ?? fallbackNeedsApplicant;
const awaitingPaymentCount =
analytics?.kpis.awaitingPayment ?? fallbackAwaitingPayment;
const overdueSlaCount = analytics?.kpis.overdueSla ?? 0;
const activeLicensesCount = analytics?.kpis.activeLicenses ?? 0;
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}> // Department counts
const seafarerRegCount =
analytics?.departmentSummary.seafarerRegistrations.submitted ??
countOf(registrations);
const seamanBookCount =
analytics?.departmentSummary.seamanBooks.pending ?? countOf(seamanBooks);
const btcCount =
analytics?.departmentSummary.btc.pending ?? countOf(btcDocuments);
const vesselCount =
analytics?.departmentSummary.vessels.total ?? countOf(vesselsQuery);
// Revenue display
const totalRevenue = analytics?.kpis.revenue.totalCollected ?? 0;
const formattedRevenue =
totalRevenue > 0
? `${totalRevenue.toLocaleString('en-US')} ETB`
: 'Active';
// Oldest first sorting for unclaimed worklist
const byAge = useMemo(() => {
return [...unclaimed].sort((a, b) =>
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
);
}, [unclaimed]);
const pagedUnclaimed = table.paginate(byAge.slice(0, 8));
// Urgent SLA applications list
const urgentApps = analytics?.urgentApplications ?? [];
const handleRefreshAll = () => {
analyticsQuery.refetch();
queue.refetch();
mine.refetch();
registrations.refetch();
seamanBooks.refetch();
notify.success('Dashboard metrics refreshed.');
};
const periodLabelMap: Record<string, string> = {
'7d': 'Last 7 Days',
'30d': 'Last 30 Days',
'6m': 'Last 6 Months',
'1y': 'Last 1 Year',
};
// CSV Report Generator
const handleExportCsv = () => {
const lines: string[] = [];
lines.push('ETHIOPIAN MARITIME AUTHORITY - OPERATIONS DASHBOARD REPORT');
lines.push(`Generated At,${new Date().toISOString()}`);
lines.push(`Time Window,${periodLabelMap[period] ?? period}`);
lines.push('');
// Section 1: Executive KPIs
lines.push('--- EXECUTIVE KPIS ---');
lines.push('Metric,Value');
lines.push(`Awaiting Claim (Unclaimed Pool),${unclaimedCount}`);
lines.push(`Assigned to Me,${assignedToMeCount}`);
lines.push(`In Review (All Staff),${inProgressTotalCount}`);
lines.push(`Action Required (Applicant Edits),${needsApplicantCount}`);
lines.push(`Awaiting Payment,${awaitingPaymentCount}`);
lines.push(`SLA Overdue,${overdueSlaCount}`);
lines.push(`SLA Compliance Rate,${analytics?.kpis.slaComplianceRate ?? 100}%`);
lines.push(`Active Registered Licences,${activeLicensesCount}`);
lines.push(`Total Settled Revenue (ETB),${totalRevenue}`);
lines.push('');
// Section 2: Department Summary
lines.push('--- DEPARTMENT WORKLOADS ---');
lines.push('Department / Service,Pending Count,Total Count');
lines.push(
`Seafarer Registrations,${seafarerRegCount ?? 0},${analytics?.departmentSummary.seafarerRegistrations.total ?? 0}`,
);
lines.push(
`Seaman Books,${seamanBookCount ?? 0},${analytics?.departmentSummary.seamanBooks.total ?? 0}`,
);
lines.push(
`Basic Training (BTC),${btcCount ?? 0},${analytics?.departmentSummary.btc.total ?? 0}`,
);
lines.push(
`Vessel Registry,${analytics?.departmentSummary.vessels.pending ?? 0},${vesselCount ?? 0}`,
);
lines.push('');
// Section 3: Time Series
if (analytics?.monthlyTrend && analytics.monthlyTrend.length > 0) {
lines.push('--- INTAKE & DECISIONS TREND ---');
lines.push('Period,Submitted,Approved / Issued,Rejected,Revenue (ETB)');
for (const row of analytics.monthlyTrend) {
lines.push(
`"${row.month} ${row.year}",${row.submitted},${row.approved},${row.rejected},${row.revenue}`,
);
}
lines.push('');
}
// Section 4: Category Breakdown
if (analytics?.categoryBreakdown && analytics.categoryBreakdown.length > 0) {
lines.push('--- CATEGORY BREAKDOWN ---');
lines.push('Category,Applications Count,Percentage');
for (const cat of analytics.categoryBreakdown) {
lines.push(`"${cat.category}",${cat.count},${cat.percentage}%`);
}
lines.push('');
}
// Section 5: Urgent Applications
if (urgentApps.length > 0) {
lines.push('--- SLA PRIORITY WORKLIST ---');
lines.push(
'Application Number,Company / Applicant,License Type,Category,Status,Hours Remaining / Overdue',
);
for (const app of urgentApps) {
lines.push(
`"${app.applicationNumber}","${app.companyName ?? 'Applicant'}","${app.licenseTypeName}","${app.category}","${app.isOverdue ? 'OVERDUE' : 'AT RISK'}","${app.hoursLeft}h"`,
);
}
lines.push('');
}
const csvContent = lines.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute(
'download',
`EMA-Operations-Dashboard-${period}-${new Date().toISOString().slice(0, 10)}.csv`,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
notify.success('Executive dashboard CSV report generated and downloaded.');
};
const isInitialLoading = queue.isLoading && analyticsQuery.isLoading;
if (isInitialLoading) {
return <PageLoader label="Loading Backoffice Operations Dashboard…" height={450} />;
}
return (
<Stack gap="xl">
{/* Header & Quick Action Jump Bar */}
<Box>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<PageHeader
title="Operations Dashboard"
subtitle="Central administrative hub for review queues, turnaround SLAs, and departmental operations."
noMargin
/>
<Group gap="xs" wrap="wrap">
{/* Time Window Selector */}
<SegmentedControl
size="sm"
radius="md"
value={period}
onChange={setPeriod}
data={[
{ label: '7 Days', value: '7d' },
{ label: '30 Days', value: '30d' },
{ label: '6 Months', value: '6m' },
{ label: '1 Year', value: '1y' },
]}
/>
<Tooltip label="Export full dashboard summary as CSV">
<Button
variant="default"
size="sm"
leftSection={<IconDownload size={15} />}
onClick={handleExportCsv}
>
Export CSV
</Button>
</Tooltip>
<Tooltip label="Refresh all live data">
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={handleRefreshAll}
loading={analyticsQuery.isFetching || queue.isFetching}
>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Button
variant="light"
color="blue"
size="sm"
leftSection={<IconInbox size={16} />}
onClick={() => navigate('/licence-review')}
>
Licence Queue
</Button>
<Button
variant="light"
color="teal"
size="sm"
leftSection={<IconUserCheck size={16} />}
onClick={() => navigate('/seafarer-registrations')}
>
Seafarers
</Button>
<Button
variant="light"
color="cyan"
size="sm"
leftSection={<IconShip size={16} />}
onClick={() => navigate('/vessel-registration-queue')}
>
Vessels
</Button>
</Group>
</Group>
</Box>
{/* SLA Alert Banner if work has breached deadline */}
{overdueSlaCount > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<IconAlertTriangle size={20} />}
title="Attention: SLA Turnaround Threshold Exceeded"
>
<Group justify="space-between" align="center" wrap="wrap">
<Text size="sm">
<Text span fw={700}>
{overdueSlaCount} {overdueSlaCount === 1 ? 'application' : 'applications'}
</Text>{' '}
have exceeded the official service level agreement timeframe and require immediate officer action.
</Text>
<Button
size="xs"
color="red"
variant="outline"
onClick={() => navigate('/licence-review')}
>
Review Overdue Work
</Button>
</Group>
</Alert>
)}
{/* Primary Executive KPI Tiles (Row of 6) */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 6 }} spacing="md">
<StatTile <StatTile
label="Awaiting claim" label="Awaiting Claim"
value={unclaimed.length} value={unclaimedCount}
hint="Licence applications nobody has picked up" hint="Unclaimed pool in queue"
icon={IconInbox} icon={IconInbox}
tone="info" tone="info"
onClick={() => navigate('/licence-review')} onClick={() => navigate('/licence-review')}
/> />
<StatTile <StatTile
label="Assigned to me" label="Assigned to Me"
value={inProgress.length} value={assignedToMeCount}
hint="Your open licence reviews" hint="Your claimed active reviews"
icon={IconFileText} icon={IconFileText}
tone="neutral" tone="neutral"
onClick={() => navigate('/licence-review')} onClick={() => navigate('/licence-review')}
/> />
<StatTile <StatTile
label="Needs applicant action" label="In Review (All)"
value={needsApplicant} value={inProgressTotalCount}
hint="Returned for corrections" hint="Under review across all staff"
icon={IconUsers}
tone="info"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Action Required"
value={needsApplicantCount}
hint="Returned for applicant edits"
icon={IconAlertTriangle} icon={IconAlertTriangle}
tone="pending" tone="pending"
/> />
<StatTile <StatTile
label="Awaiting payment" label="Awaiting Payment"
value={awaitingPayment} value={awaitingPaymentCount}
hint="Approved, fee not yet settled" hint="Approved, fee unsettled"
icon={IconCreditCard} icon={IconCreditCard}
tone="warning" tone="warning"
/> />
</SimpleGrid>
{/* Same 4-column track as the row above, so a two-tile row lines up with
it instead of stretching each tile to half the page. */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile <StatTile
label="Seafarer registrations" label="SLA Overdue"
value={show(countOf(registrations))} value={overdueSlaCount}
hint="Submitted, awaiting review" hint={overdueSlaCount > 0 ? 'Urgent attention required' : 'All within SLA window'}
icon={IconUserCheck} icon={IconClockExclamation}
tone="info" tone={overdueSlaCount > 0 ? 'danger' : 'neutral'}
onClick={() => navigate('/seafarer-registrations')} onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Seaman books"
value={show(countOf(seamanBooks))}
hint="Released, awaiting payment"
icon={IconCreditCard}
tone="pending"
onClick={() => navigate('/seaman-book-queue')}
/> />
</SimpleGrid> </SimpleGrid>
{/* Multi-Department Operations & Cross-Functional Strip */}
<Card withBorder radius="lg" p="md">
<Group justify="space-between" mb="xs">
<Group gap="xs">
<ThemeIcon size={24} radius="sm" variant="light" color="blue">
<IconFolders size={15} />
</ThemeIcon>
<Text fw={600} size="sm">
Cross-Department Workloads & Revenue
</Text>
</Group>
<Text size="xs" c="dimmed">
Live pipeline counts across Maritime Authority desks
</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 5 }} spacing="md" mt="sm">
{/* Dept 1: Seafarer Registrations */}
<Box
p="sm"
style={{
cursor: 'pointer',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-default-hover)',
transition: 'transform 120ms ease',
}}
onClick={() => navigate('/seafarer-registrations')}
>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Seafarer Regs
</Text>
<ThemeIcon size={20} radius="xl" variant="light" color="teal">
<IconUserCheck size={12} />
</ThemeIcon>
</Group>
<Text fz={22} fw={800} c="teal">
{show(seafarerRegCount)}
</Text>
<Text size="xs" c="dimmed">
Submitted for review
</Text>
</Box>
{/* Dept 2: Seaman Books */}
<Box
p="sm"
style={{
cursor: 'pointer',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-default-hover)',
}}
onClick={() => navigate('/seaman-book-queue')}
>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Seaman Books
</Text>
<ThemeIcon size={20} radius="xl" variant="light" color="indigo">
<IconCertificate size={12} />
</ThemeIcon>
</Group>
<Text fz={22} fw={800} c="indigo">
{show(seamanBookCount)}
</Text>
<Text size="xs" c="dimmed">
Pending fee & issuance
</Text>
</Box>
{/* Dept 3: Basic Training Certificates */}
<Box
p="sm"
style={{
cursor: 'pointer',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-default-hover)',
}}
onClick={() => navigate('/btc-queue')}
>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
BTC Training
</Text>
<ThemeIcon size={20} radius="xl" variant="light" color="blue">
<IconCertificate size={12} />
</ThemeIcon>
</Group>
<Text fz={22} fw={800} c="blue">
{show(btcCount)}
</Text>
<Text size="xs" c="dimmed">
Certificates in progress
</Text>
</Box>
{/* Dept 4: Vessel Registry */}
<Box
p="sm"
style={{
cursor: 'pointer',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-default-hover)',
}}
onClick={() => navigate('/vessel-registration-queue')}
>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Vessel Registry
</Text>
<ThemeIcon size={20} radius="xl" variant="light" color="cyan">
<IconShip size={12} />
</ThemeIcon>
</Group>
<Text fz={22} fw={800} c="cyan">
{show(vesselCount)}
</Text>
<Text size="xs" c="dimmed">
Vessels registered
</Text>
</Box>
{/* Dept 5: Settled Fees / Revenue */}
<Box
p="sm"
style={{
borderRadius: 8,
backgroundColor: 'var(--mantine-color-default-hover)',
}}
>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Settled Fees
</Text>
<ThemeIcon size={20} radius="xl" variant="light" color="green">
<IconCash size={12} />
</ThemeIcon>
</Group>
<Text fz={20} fw={800} c="green" lineClamp={1}>
{formattedRevenue}
</Text>
<Text size="xs" c="dimmed">
Collections to date
</Text>
</Box>
</SimpleGrid>
</Card>
{/* Interactive Visualizations & Charts with Selected Period Label */}
<DashboardCharts
analytics={analytics}
periodLabel={periodLabelMap[period] ?? period}
fallbackStatusDistribution={[
{ name: 'Awaiting Claim', value: unclaimed.length, color: '#339af0' },
{ name: 'Assigned to Me', value: inProgress.length, color: '#4c6ef5' },
{ name: 'Action Required', value: fallbackNeedsApplicant, color: '#fab005' },
{ name: 'Awaiting Payment', value: fallbackAwaitingPayment, color: '#fd7e14' },
]}
/>
{/* Operational Worklists & Actionable Queues */}
<Grid gutter="lg"> <Grid gutter="lg">
{/* Main Worklist: Oldest Unclaimed Pool or SLA Critical */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
<AdvancedTable<LicenseApplication> <Card withBorder radius="lg" p="md" h="100%">
title="Awaiting claim — oldest first" <Tabs
tableName="Awaiting claim" value={activeTab}
columns={dashboardQueueColumns} onChange={setActiveTab}
data={paged.rows} variant="pills"
itemCount={paged.itemCount} radius="md"
pageIndex={paged.pageIndex} >
onPageChange={table.setPageIndex} <Group justify="space-between" mb="md" wrap="nowrap">
pageSize={table.pageSize} <Tabs.List>
onRowClick={(row) => navigate(`/licence-review/${row.id}`)} <Tabs.Tab
refresh={queue.refetch} value="unclaimed"
isLoading={queue.isFetching} leftSection={<IconInbox size={15} />}
emptyText="Nothing waiting to be claimed." >
toolbar={ Unclaimed Work Pool ({byAge.length})
<Anchor size="sm" onClick={() => navigate('/licence-review')}> </Tabs.Tab>
Open queue <Tabs.Tab
</Anchor> value="sla"
} leftSection={<IconClock size={15} />}
/> color={urgentApps.length > 0 ? 'red' : 'gray'}
>
SLA Priority ({urgentApps.length})
</Tabs.Tab>
</Tabs.List>
<Anchor
size="sm"
c="blue"
onClick={() => navigate('/licence-review')}
>
View Full Queue <IconChevronRight size={12} />
</Anchor>
</Group>
<Tabs.Panel value="unclaimed">
<AdvancedTable<LicenseApplication>
title="Awaiting Claim — Oldest First"
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={pagedUnclaimed.rows}
itemCount={pagedUnclaimed.itemCount}
pageIndex={pagedUnclaimed.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
refresh={queue.refetch}
isLoading={queue.isFetching}
emptyText="No applications currently waiting to be claimed."
/>
</Tabs.Panel>
<Tabs.Panel value="sla">
{urgentApps.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
All applications are safely within their service level agreement deadlines.
</Text>
) : (
<Stack gap="xs">
{urgentApps.map((item) => (
<Card
key={item.id}
withBorder
p="sm"
radius="md"
style={{
cursor: 'pointer',
borderColor: item.isOverdue
? 'var(--mantine-color-red-outline)'
: 'var(--mantine-color-yellow-outline)',
}}
onClick={() => navigate(`/licence-review/${item.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={28}
radius="md"
variant="light"
color={item.isOverdue ? 'red' : 'yellow'}
>
<IconClockExclamation size={16} />
</ThemeIcon>
<div>
<Text fw={600} size="sm" c="blue">
{item.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{item.companyName ?? 'Applicant'} {item.licenseTypeName}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Badge
variant="light"
color={item.isOverdue ? 'red' : 'yellow'}
size="sm"
>
{item.isOverdue
? `Overdue by ${Math.abs(item.hoursLeft)}h`
: `${item.hoursLeft}h remaining`}
</Badge>
<IconChevronRight size={14} color="var(--mantine-color-dimmed)" />
</Group>
</Group>
</Card>
))}
</Stack>
)}
</Tabs.Panel>
</Tabs>
</Card>
</Grid.Col> </Grid.Col>
{/* Side Panel: Longest Waiting & Fast Directory */}
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Paper withBorder radius="lg" p="lg" h="100%"> <Stack gap="md" h="100%">
<Text fw={600} size="sm" mb="xs"> {/* Longest Waiting Applications Card */}
Longest waiting <Paper withBorder radius="lg" p="lg">
</Text> <Group justify="space-between" mb="xs">
<Text size="xs" c="dimmed" mb="md"> <Text fw={600} size="sm">
Unclaimed applications, by how long they have sat. Longest Waiting Applications
</Text>
<Stack gap="sm">
{byAge.slice(0, 5).map((app) => (
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
<Anchor
size="sm"
lineClamp={1}
onClick={() => navigate(`/licence-review/${app.id}`)}
>
{app.applicationNumber}
</Anchor>
<WaitingFor
since={app.submittedAt ?? app.createdAt}
slaDays={
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
}
/>
</Group>
))}
{byAge.length === 0 && (
<Text size="sm" c="dimmed">
Nothing waiting.
</Text> </Text>
)} <IconClock size={16} color="var(--mantine-color-dimmed)" />
</Stack> </Group>
</Paper> <Text size="xs" c="dimmed" mb="md">
Unclaimed applications sitting in queue by age.
</Text>
<Stack gap="sm">
{byAge.slice(0, 5).map((app) => (
<Group
key={app.id}
justify="space-between"
wrap="nowrap"
gap="sm"
>
<Anchor
size="sm"
lineClamp={1}
onClick={() => navigate(`/licence-review/${app.id}`)}
fw={500}
>
{app.applicationNumber}
</Anchor>
<WaitingFor
since={app.submittedAt ?? app.createdAt}
slaDays={
app.licenseType?.slaHours
? app.licenseType.slaHours / 24
: undefined
}
/>
</Group>
))}
{byAge.length === 0 && (
<Text size="sm" c="dimmed" py="xs">
Nothing waiting to be claimed.
</Text>
)}
</Stack>
</Paper>
{/* Quick Operations Navigation Directory */}
<Paper withBorder radius="lg" p="lg" style={{ flexGrow: 1 }}>
<Text fw={600} size="sm" mb="xs">
Quick Authority Desks
</Text>
<Text size="xs" c="dimmed" mb="md">
Direct access to core backoffice review workspaces.
</Text>
<Stack gap="xs">
<Button
variant="subtle"
color="gray"
justify="space-between"
rightSection={<IconChevronRight size={14} />}
fullWidth
onClick={() => navigate('/licence-review')}
>
<Group gap="xs">
<IconInbox size={16} color="var(--mantine-color-blue-filled)" />
<Text size="sm">Licence Review Queue</Text>
</Group>
</Button>
<Button
variant="subtle"
color="gray"
justify="space-between"
rightSection={<IconChevronRight size={14} />}
fullWidth
onClick={() => navigate('/licence-register')}
>
<Group gap="xs">
<IconCertificate size={16} color="var(--mantine-color-teal-filled)" />
<Text size="sm">Official Licence Register</Text>
</Group>
</Button>
<Button
variant="subtle"
color="gray"
justify="space-between"
rightSection={<IconChevronRight size={14} />}
fullWidth
onClick={() => navigate('/seafarer-registrations')}
>
<Group gap="xs">
<IconUserCheck size={16} color="var(--mantine-color-indigo-filled)" />
<Text size="sm">Seafarer Registrations</Text>
</Group>
</Button>
<Button
variant="subtle"
color="gray"
justify="space-between"
rightSection={<IconChevronRight size={14} />}
fullWidth
onClick={() => navigate('/seaman-book-queue')}
>
<Group gap="xs">
<IconCreditCard size={16} color="var(--mantine-color-orange-filled)" />
<Text size="sm">Seaman Books & BTC</Text>
</Group>
</Button>
<Button
variant="subtle"
color="gray"
justify="space-between"
rightSection={<IconChevronRight size={14} />}
fullWidth
onClick={() => navigate('/vessel-registration-queue')}
>
<Group gap="xs">
<IconShip size={16} color="var(--mantine-color-cyan-filled)" />
<Text size="sm">Vessel Register Queue</Text>
</Group>
</Button>
<Button
variant="subtle"
color="gray"
justify="space-between"
rightSection={<IconChevronRight size={14} />}
fullWidth
onClick={() => navigate('/exams')}
>
<Group gap="xs">
<IconFolders size={16} color="var(--mantine-color-grape-filled)" />
<Text size="sm">Examinations & CoC</Text>
</Group>
</Button>
</Stack>
</Paper>
</Stack>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Stack> </Stack>

View File

@@ -12,6 +12,7 @@ import {
IconFileUpload, IconFileUpload,
IconMessage, IconMessage,
IconArrowRight, IconArrowRight,
IconPencilCheck,
IconUserCheck, IconUserCheck,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -19,10 +20,16 @@ import {
STATUS_COLORS, STATUS_COLORS,
STATUS_LABELS, STATUS_LABELS,
type ApplicationDetail, type ApplicationDetail,
type ApplicationRemark,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment'; type EntryKind =
| 'status'
| 'remark'
| 'correction'
| 'upload'
| 'assignment';
interface ActivityEntry { interface ActivityEntry {
id: string; id: string;
@@ -37,6 +44,7 @@ interface ActivityEntry {
const ICONS: Record<EntryKind, typeof IconArrowRight> = { const ICONS: Record<EntryKind, typeof IconArrowRight> = {
status: IconArrowRight, status: IconArrowRight,
remark: IconMessage, remark: IconMessage,
correction: IconPencilCheck,
upload: IconFileUpload, upload: IconFileUpload,
assignment: IconUserCheck, assignment: IconUserCheck,
}; };
@@ -50,7 +58,18 @@ const ICONS: Record<EntryKind, typeof IconArrowRight> = {
* the trade-off is that it can only show what the detail payload carries, and * the trade-off is that it can only show what the detail payload carries, and
* notifications sent to the applicant are not among them. * notifications sent to the applicant are not among them.
*/ */
export function ActivityRail({ detail }: { detail: ApplicationDetail }) { export function ActivityRail({
detail,
remarkLabel,
}: {
detail: ApplicationDetail;
/**
* Names a remark's target for the officer. The page owns this because it
* holds the licence type config the labels come from; without it the trail
* falls back to the raw key, which for a staff remark is a uuid.
*/
remarkLabel?: (remark: ApplicationRemark) => string;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
@@ -82,18 +101,37 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
} }
for (const remark of detail.remarks ?? []) { for (const remark of detail.remarks ?? []) {
const target = remarkLabel?.(remark) ?? remark.targetKey;
merged.push({ merged.push({
id: `remark-${remark.id}`, id: `remark-${remark.id}`,
kind: 'remark', kind: 'remark',
at: remark.createdAt, at: remark.createdAt,
actor: t('review.activity.officer', 'Officer'), actor: t('review.activity.officer', 'Officer'),
title: t('review.activity.remarkOn', { title: t('review.activity.remarkOn', {
target: remark.targetKey, target,
defaultValue: 'Correction requested on {{target}}', defaultValue: 'Correction requested on {{target}}',
}), }),
detail: remark.remark, detail: remark.remark,
color: remark.resolvedAt ? 'teal' : 'orange', color: remark.resolvedAt ? 'teal' : 'orange',
}); });
// The correction itself, at the moment the data moved. Distinct from the
// remark above (which is the *request*) and from `resolvedAt`, which only
// records the applicant ticking the item off — the portal does that in
// bulk on resubmit, so it says nothing about what was actually edited.
if (remark.valueChangedAt) {
merged.push({
id: `correction-${remark.id}`,
kind: 'correction',
at: remark.valueChangedAt,
actor: t('review.activity.applicant', 'Applicant'),
title: t('review.activity.correctedTarget', {
target,
defaultValue: 'Corrected {{target}}',
}),
color: 'blue',
});
}
} }
for (const attachment of detail.attachments ?? []) { for (const attachment of detail.attachments ?? []) {
@@ -118,7 +156,7 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
return merged.sort( return merged.sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(), (a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
); );
}, [detail, t]); }, [detail, remarkLabel, t]);
if (entries.length === 0) { if (entries.length === 0) {
return ( return (

View File

@@ -0,0 +1,121 @@
import { Alert, Badge, Group, List, Text } from "@mantine/core";
import { IconPencilCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDateDisplayer } from "@ema-platform/shared";
export interface CorrectedItem {
/** Section key, document key, or ApplicationStaff id. */
key: string;
/** What the officer reads — a section title, a document name, a person. */
label: string;
/** What the officer asked for, so the correction can be judged against it. */
remark: string;
/** When the applicant actually changed the value. */
changedAt: string;
}
interface CorrectedItemsPanelProps {
round: number;
items: CorrectedItem[];
/** Flagged items the applicant ticked off without changing anything. */
untouched: CorrectedItem[];
}
/**
* What came back in the resubmission, item by item.
*
* A returned application announced itself with a "round 2" badge and nothing
* else, so re-reviewing meant re-reading the whole file to find the two fields
* that moved. This names them, alongside the remark that asked for each, and
* separates out the items the applicant marked done but never actually edited —
* the ones most likely to come back a third time.
*
* `valueChangedAt` is what makes the distinction possible: the portal resolves
* every remark of the round in bulk on resubmit, so `isResolved` says only that
* the applicant pressed the button.
*/
export function CorrectedItemsPanel({
round,
items,
untouched,
}: CorrectedItemsPanelProps) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
if (items.length === 0 && untouched.length === 0) return null;
return (
<Alert
mb="md"
color={items.length > 0 ? "blue" : "orange"}
variant="light"
icon={<IconPencilCheck size={16} />}
title={
<Group gap="xs">
<Text fw={600} size="sm">
{t("review.corrected.title", {
count: items.length,
defaultValue_one: "1 item corrected in round {{round}}",
defaultValue_other:
"{{count}} items corrected in round {{round}}",
round,
})}
</Text>
{untouched.length > 0 && (
<Badge size="xs" color="orange" variant="light">
{t("review.corrected.untouchedBadge", {
count: untouched.length,
defaultValue: "{{count}} unchanged",
})}
</Badge>
)}
</Group>
}
>
{items.length > 0 && (
<List size="sm" spacing={4}>
{items.map((item) => (
<List.Item key={item.key}>
<Text size="sm" fw={500} component="span">
{item.label}
</Text>
<Text size="xs" c="dimmed">
{t("review.corrected.against", {
remark: item.remark,
defaultValue: "Asked: {{remark}}",
})}{" "}
· {showDate(item.changedAt.slice(0, 10))}
</Text>
</List.Item>
))}
</List>
)}
{untouched.length > 0 && (
<>
<Text size="xs" fw={600} mt={items.length > 0 ? "sm" : 0}>
{t(
"review.corrected.untouched",
"Marked done but left unchanged — re-read these first",
)}
</Text>
<List size="sm" spacing={4}>
{untouched.map((item) => (
<List.Item key={item.key}>
<Text size="sm" component="span">
{item.label}
</Text>
<Text size="xs" c="dimmed">
{t("review.corrected.against", {
remark: item.remark,
defaultValue: "Asked: {{remark}}",
})}
</Text>
</List.Item>
))}
</List>
</>
)}
</Alert>
);
}

View File

@@ -20,6 +20,7 @@ import {
IconDownload, IconDownload,
IconEye, IconEye,
IconFileText, IconFileText,
IconPencilCheck,
IconRotate, IconRotate,
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -43,6 +44,11 @@ interface DocumentsTabProps {
requirements: DocumentRequirement[]; requirements: DocumentRequirement[];
/** Applicant answers used to evaluate conditional document requirements. */ /** Applicant answers used to evaluate conditional document requirements. */
formData: Record<string, Record<string, unknown>>; formData: Record<string, Record<string, unknown>>;
/**
* Document keys the applicant actually re-uploaded during the open
* correction round — the files worth re-opening first on a resubmission.
*/
correctedKeys?: Set<string>;
/** documentKey -> remark. Owned by the review page. */ /** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>; flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void; onToggleFlag: (documentKey: string) => void;
@@ -63,6 +69,7 @@ export function DocumentsTab({
attachments, attachments,
requirements, requirements,
formData, formData,
correctedKeys,
flags, flags,
onToggleFlag, onToggleFlag,
onFlagRemark, onFlagRemark,
@@ -243,6 +250,16 @@ export function DocumentsTab({
{t("review.documents.flagged", "Correction requested")} {t("review.documents.flagged", "Correction requested")}
</Badge> </Badge>
)} )}
{correctedKeys?.has(attachment.documentKey) && (
<Badge
color="blue"
variant="light"
size="sm"
leftSection={<IconPencilCheck size={11} />}
>
{t("review.corrected.badge", "Corrected")}
</Badge>
)}
</Group> </Group>
<Text size="xs" c="dimmed" truncate> <Text size="xs" c="dimmed" truncate>
{file?.originalName ?? {file?.originalName ??

View File

@@ -9,7 +9,11 @@ import {
TextInput, TextInput,
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react'; import {
IconAlertTriangle,
IconMapPin,
IconPencilCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
conditionHolds, conditionHolds,
@@ -34,6 +38,12 @@ interface FormDetailsTabProps {
/** The licence type's form schema — the order and labels to render by. */ /** The licence type's form schema — the order and labels to render by. */
configSections: FormSectionConfig[]; configSections: FormSectionConfig[];
currency?: string; currency?: string;
/**
* Section keys the applicant actually edited during the open correction
* round, so a returning application points at itself instead of making the
* officer re-read every section to find the two that moved.
*/
correctedKeys?: Set<string>;
/** sectionKey -> remark. Owned by the review page. */ /** sectionKey -> remark. Owned by the review page. */
flags: Record<string, { remark: string }>; flags: Record<string, { remark: string }>;
onToggleFlag: (sectionKey: string) => void; onToggleFlag: (sectionKey: string) => void;
@@ -63,6 +73,7 @@ export function FormDetailsTab({
formData, formData,
configSections, configSections,
currency, currency,
correctedKeys,
flags, flags,
onToggleFlag, onToggleFlag,
onFlagRemark, onFlagRemark,
@@ -145,6 +156,16 @@ export function FormDetailsTab({
<Text fw={600} size="sm"> <Text fw={600} size="sm">
{section.title} {section.title}
</Text> </Text>
{correctedKeys?.has(section.key) && (
<Badge
size="xs"
color="blue"
variant="light"
leftSection={<IconPencilCheck size={10} />}
>
{t('review.corrected.badge', 'Corrected')}
</Badge>
)}
{missing > 0 && ( {missing > 0 && (
<Tooltip <Tooltip
label={t( label={t(

View File

@@ -9,36 +9,73 @@ const REASONS = {
needsFlags: "needs flags", needsFlags: "needs flags",
needsCapital: "needs capital", needsCapital: "needs capital",
needsInspection: "needs inspection", needsInspection: "needs inspection",
inspectionFailed: "inspection failed",
needsDocumentReviews: "needs document reviews", needsDocumentReviews: "needs document reviews",
inspectionNotYetDue: "not yet due", inspectionNotYetDue: "not yet due",
}; };
/** Held by someone else, so ownership gating is what the assertions turn on. */ /** Held by someone else, so ownership gating is what the assertions turn on. */
function detailFor(licenseTypeKey: string): ApplicationDetail { function detailFor(
licenseTypeKey: string,
certificateCategory?: string | null,
): ApplicationDetail {
return { return {
application: { application: {
id: "app-1", id: "app-1",
status: "SUBMITTED", status: "SUBMITTED",
assignedOfficerId: "another-officer", assignedOfficerId: "another-officer",
licenseType: { key: licenseTypeKey }, licenseType:
certificateCategory === undefined
? { key: licenseTypeKey }
: { key: licenseTypeKey, certificateCategory },
}, },
availableEvents: ["claim", "assign", "request-adjustment"], availableEvents: ["claim", "assign", "request-adjustment"],
} as unknown as ApplicationDetail; } as unknown as ApplicationDetail;
} }
function resolve(licenseTypeKey: string) { function resolve(licenseTypeKey: string, certificateCategory?: string | null) {
return resolveActions({ return resolveActions({
detail: detailFor(licenseTypeKey), detail: detailFor(licenseTypeKey, certificateCategory),
currentUserId: "me", currentUserId: "me",
can: () => true, can: () => true,
reasons: REASONS, reasons: REASONS,
flaggedCount: 1, flaggedCount: 1,
hasPendingInspection: false, hasPendingInspection: false,
inspectionNotYetDue: false, inspectionNotYetDue: false,
latestInspectionPassed: null,
allDocumentsAccepted: true, allDocumentsAccepted: true,
}); });
} }
/**
* An inspected licence sitting on INSPECTION_COMPLETED, which is the only
* status Final Approve fires from — so the inspection *result* is all that
* separates an approvable file from an unapprovable one.
*/
function inspected(latestInspectionPassed: boolean | null) {
const detail = {
application: {
id: "app-1",
status: "INSPECTION_COMPLETED",
assignedOfficerId: "me",
licenseType: { key: "VESSEL_REGISTRATION", inspectionRequired: true },
},
availableEvents: ["final-approve", "request-adjustment", "reject"],
} as unknown as ApplicationDetail;
return resolveActions({
detail,
currentUserId: "me",
can: () => true,
reasons: REASONS,
flaggedCount: 1,
hasPendingInspection: false,
inspectionNotYetDue: false,
latestInspectionPassed,
allDocumentsAccepted: true,
}).find((a) => a.id === "final-approve");
}
describe("resolveActions — seafarer certificates skip the queue", () => { describe("resolveActions — seafarer certificates skip the queue", () => {
it("drops claim and assign for a CoC", () => { it("drops claim and assign for a CoC", () => {
const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id); const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id);
@@ -61,4 +98,133 @@ describe("resolveActions — seafarer certificates skip the queue", () => {
expect(adjust?.enabled).toBe(false); expect(adjust?.enabled).toBe(false);
expect(adjust?.disabledReason).toBe(REASONS.notAssigned); expect(adjust?.disabledReason).toBe(REASONS.notAssigned);
}); });
// The Behaviour tab's `certificateCategory` decides, not the key: a type an
// administrator configured as a CoC skips the queue whatever it is called,
// and a type they cleared the category on is queued like any licence.
it("skips the queue for a type configured as a CoC under any key", () => {
const ids = resolve("MASTER_MARINER", "COC").map((a) => a.id);
expect(ids).not.toContain("claim");
expect(ids).not.toContain("assign");
});
it("queues a certificate-looking key whose category was cleared", () => {
const actions = resolve("COC_MASTER", null);
expect(actions.map((a) => a.id)).toContain("claim");
const adjust = actions.find((a) => a.id === "request-adjustment");
expect(adjust?.enabled).toBe(false);
});
it("keeps endorsements in the queue", () => {
expect(resolve("ENDORSEMENT_SEAFARER", "ENDORSEMENT").map((a) => a.id)).toContain(
"claim",
);
});
});
/**
* A completed inspection is not a passed inspection.
*
* The status alone used to unlock approval, so a vessel that failed its visit
* before INSPECTION_FAILED existed — the row stayed on INSPECTION_COMPLETED —
* offered a live Approve button the server then refused with
* `inspection_not_passed`.
*/
describe("resolveActions — approval waits on a passed inspection", () => {
it("disables final approve when the latest inspection failed", () => {
const approve = inspected(false);
expect(approve?.enabled).toBe(false);
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
});
it("disables final approve when no inspection was conducted", () => {
const approve = inspected(null);
expect(approve?.enabled).toBe(false);
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
});
it("enables final approve once an inspection passed", () => {
expect(inspected(true)?.enabled).toBe(true);
});
});
/**
* A vessel registration is gated on the visit by its type, not only by the
* admin-editable `inspectionRequired` flag — turning the flag off must not
* let a vessel be approved, or its certificate issued, on paperwork alone.
*/
describe("resolveActions — vessel registration needs a passed inspection", () => {
function vessel(
status: string,
events: string[],
latestInspectionPassed: boolean | null,
licenseTypeKey = "VESSEL_REGISTRATION",
) {
const detail = {
application: {
id: "app-1",
status,
assignedOfficerId: "me",
licenseType: { key: licenseTypeKey, inspectionRequired: false },
},
availableEvents: events,
} as unknown as ApplicationDetail;
return resolveActions({
detail,
currentUserId: "me",
can: () => true,
reasons: REASONS,
flaggedCount: 1,
hasPendingInspection: false,
inspectionNotYetDue: false,
latestInspectionPassed,
allDocumentsAccepted: true,
});
}
it("disables final approve with no passed inspection, flag or not", () => {
const approve = vessel("UNDER_EVALUATION", ["final-approve"], null).find(
(a) => a.id === "final-approve",
);
expect(approve?.enabled).toBe(false);
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
});
it("disables final approve after a failed inspection", () => {
const approve = vessel("UNDER_EVALUATION", ["final-approve"], false).find(
(a) => a.id === "final-approve",
);
expect(approve?.enabled).toBe(false);
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
});
it("enables final approve once the inspection passed", () => {
const approve = vessel("UNDER_EVALUATION", ["final-approve"], true).find(
(a) => a.id === "final-approve",
);
expect(approve?.enabled).toBe(true);
});
it("withholds the certificate until the inspection passed", () => {
const issue = vessel("SCHEDULED", ["issue-certificate"], null).find(
(a) => a.id === "issue-certificate",
);
expect(issue?.enabled).toBe(false);
expect(issue?.disabledReason).toBe(REASONS.needsInspection);
expect(
vessel("SCHEDULED", ["issue-certificate"], true).find(
(a) => a.id === "issue-certificate",
)?.enabled,
).toBe(true);
});
it("leaves a non-inspected type alone", () => {
const approve = vessel(
"UNDER_EVALUATION",
["final-approve"],
null,
"SEAFARER_REGISTRATION",
).find((a) => a.id === "final-approve");
expect(approve?.enabled).toBe(true);
});
}); });

View File

@@ -1,5 +1,8 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api'; import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { isSeafarerCertificate } from './license-types'; import {
isSeafarerCertificate,
requiresPassedInspection,
} from './license-types';
/** /**
* Where an action is rendered. One tier per action, decided here rather than * Where an action is rendered. One tier per action, decided here rather than
@@ -347,6 +350,7 @@ export interface ResolveContext {
needsFlags: string; needsFlags: string;
needsCapital: string; needsCapital: string;
needsInspection: string; needsInspection: string;
inspectionFailed: string;
needsDocumentReviews: string; needsDocumentReviews: string;
inspectionNotYetDue: string; inspectionNotYetDue: string;
}; };
@@ -360,6 +364,18 @@ export interface ResolveContext {
* button waits (the server refuses early results the same way). * button waits (the server refuses early results the same way).
*/ */
inspectionNotYetDue: boolean; inspectionNotYetDue: boolean;
/**
* Outcome of the most recent conducted visit: `true` passed, `false` failed,
* `null` none conducted yet.
*
* A completed inspection is not a passed one. Approval used to turn on the
* status alone, so an application sitting at INSPECTION_COMPLETED with a
* FAILED result behind it — what a failure produced before INSPECTION_FAILED
* existed — offered a live Approve button that the server then refused with
* `inspection_not_passed`. This is the same gate the server applies, so the
* button is dead here instead of after the click.
*/
latestInspectionPassed: boolean | null;
/** /**
* False while any uploaded document is still unjudged or rejected. Approving * False while any uploaded document is still unjudged or rejected. Approving
* is a statement that every document was checked, so the button stays dead * is a statement that every document was checked, so the button stays dead
@@ -416,7 +432,11 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
// A seafarer's certificate is reviewed by whoever opens it. There is no // A seafarer's certificate is reviewed by whoever opens it. There is no
// queue to claim it from and no reviewer to assign, so the two actions that // queue to claim it from and no reviewer to assign, so the two actions that
// move it through one are dropped and ownership stops gating the decisions. // move it through one are dropped and ownership stops gating the decisions.
const skipsAssignment = isSeafarerCertificate(app.licenseType?.key); const skipsAssignment = isSeafarerCertificate(app.licenseType);
// A vessel registration is decided on the visit, not the file: nothing may
// be approved or issued until the latest conducted inspection PASSED, and
// that holds even if the type's `inspectionRequired` flag has been edited.
const mustPassInspection = requiresPassedInspection(app.licenseType?.key);
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>( return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => { (action) => {
@@ -513,6 +533,35 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
) { ) {
return disabled(reasons.needsInspection); return disabled(reasons.needsInspection);
} }
// Mirrors the server's `inspection_not_passed`: the status says a
// visit happened, the result says whether it may be approved.
if (
(app.licenseType?.inspectionRequired || mustPassInspection) &&
ctx.latestInspectionPassed !== true
) {
return disabled(
ctx.latestInspectionPassed === false
? reasons.inspectionFailed
: reasons.needsInspection,
);
}
}
// The certificate is the other thing a passed visit unlocks. Approval
// is already gated above, so this only bites on a file that reached
// issuance some other way — but a vessel certificate with no passed
// inspection behind it is exactly the document this rule exists to
// prevent, so it is checked at the point of issue too.
if (
(action.id === 'schedule-issuance' || action.id === 'issue-certificate') &&
mustPassInspection &&
ctx.latestInspectionPassed !== true
) {
return disabled(
ctx.latestInspectionPassed === false
? reasons.inspectionFailed
: reasons.needsInspection,
);
} }
return { ...action, enabled: true }; return { ...action, enabled: true };

View File

@@ -10,7 +10,11 @@ import {
IconUsers, IconUsers,
type Icon, type Icon,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import type { LicenseApplication, LicenseType } from '@ema-platform/api'; import type {
CertificateCategory,
LicenseApplication,
LicenseType,
} from '@ema-platform/api';
/** /**
* Presentation-only metadata per licence type. * Presentation-only metadata per licence type.
@@ -152,12 +156,33 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_']; const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents']; const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
/** The configured categories that mark a type as a seafarer's own certificate. */
const SEAFARER_CERTIFICATE_CATEGORIES: ReadonlyArray<CertificateCategory> = [
'COC',
'COP',
'GOC',
];
/** /**
* CoC, CoP and the endorsement/GOC family: a seafarer's own certificate rather * CoC, CoP and the GOC family: a seafarer's own certificate rather than an
* than an organisation's licence. They skip the claim/assign queue — see * organisation's licence. They skip the claim/assign queue — see
* `resolveActions`. * `resolveActions`.
*
* Decided by the `certificateCategory` an administrator sets on the
* certificate-requirements Behaviour tab, so a type configured as a CoC in the
* backoffice is treated as one whatever its key. The key prefixes are only a
* fallback for a payload that carries no category field at all (an older
* server, or a bare key).
*/ */
export function isSeafarerCertificate(key: string | undefined): boolean { export function isSeafarerCertificate(
licenseType: Pick<LicenseType, 'key' | 'certificateCategory'> | string | undefined,
): boolean {
if (!licenseType) return false;
if (typeof licenseType !== 'string' && licenseType.certificateCategory !== undefined) {
const category = licenseType.certificateCategory;
return category !== null && SEAFARER_CERTIFICATE_CATEGORIES.includes(category);
}
const key = typeof licenseType === 'string' ? licenseType : licenseType.key;
if (!key) return false; if (!key) return false;
return ( return (
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) || CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
@@ -166,6 +191,21 @@ export function isSeafarerCertificate(key: string | undefined): boolean {
); );
} }
/**
* Types that may never be approved, nor have a certificate issued, without a
* conducted inspection that PASSED (US-VES-007 for vessel registration).
*
* Keyed on the type rather than `inspectionRequired` alone: that flag is an
* admin-editable column, and flipping it off must not quietly let a vessel be
* registered on paperwork alone. `resolveActions` applies this on top of the
* flag, never instead of it.
*/
const PASSED_INSPECTION_REQUIRED_KEYS = new Set(['VESSEL_REGISTRATION']);
export function requiresPassedInspection(key: string | undefined): boolean {
return Boolean(key && PASSED_INSPECTION_REQUIRED_KEYS.has(key));
}
/** Falls back to a generic presentation so an unseeded type still renders. */ /** Falls back to a generic presentation so an unseeded type still renders. */
export function presentationFor(key: string | undefined): LicenseTypePresentation { export function presentationFor(key: string | undefined): LicenseTypePresentation {
if (key && PRESENTATION[key]) return PRESENTATION[key]; if (key && PRESENTATION[key]) return PRESENTATION[key];

View File

@@ -55,6 +55,7 @@ import {
AmharicDatePicker, AmharicDatePicker,
type AdvancedColumn, type AdvancedColumn,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import { import {
DEFAULT_VIEW, DEFAULT_VIEW,
SAVED_VIEWS, SAVED_VIEWS,
@@ -133,6 +134,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
return type.inspectionRequired; return type.inspectionRequired;
} }
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination); if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
if (status === "SCHEDULED") return Boolean(type.requiresIssuanceScheduling);
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate; if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
return true; return true;
}); });

View File

@@ -72,6 +72,7 @@ import {
useRescheduleInspectionMutation, useRescheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation, useGetCertificateUrlForOfficerMutation,
uploadDocument, uploadDocument,
type ApplicationRemark,
type RemarkTargetType, type RemarkTargetType,
type StaffEvidenceRequirement, type StaffEvidenceRequirement,
} from "@ema-platform/api"; } from "@ema-platform/api";
@@ -92,6 +93,7 @@ import {
type DecisionSubmission, type DecisionSubmission,
} from "../../components/DecisionConfirmModal"; } from "../../components/DecisionConfirmModal";
import { ActivityRail } from "../../components/ActivityRail"; import { ActivityRail } from "../../components/ActivityRail";
import { CorrectedItemsPanel } from "../../components/CorrectedItemsPanel";
import { DocumentsTab } from "../../components/DocumentsTab"; import { DocumentsTab } from "../../components/DocumentsTab";
import { FormDetailsTab } from "../../components/FormDetailsTab"; import { FormDetailsTab } from "../../components/FormDetailsTab";
import { ApplicantCard } from "../../components/ApplicantCard"; import { ApplicantCard } from "../../components/ApplicantCard";
@@ -102,6 +104,7 @@ import { reviewStaffColumns } from "./columns";
import { import {
evaluateEligibility, evaluateEligibility,
presentationFor, presentationFor,
requiresPassedInspection,
} from "../../config/license-types"; } from "../../config/license-types";
import { import {
resolveActions, resolveActions,
@@ -331,6 +334,40 @@ export function LicenseReviewPage() {
) < pendingInspection.scheduledDate.slice(0, 10), ) < pendingInspection.scheduledDate.slice(0, 10),
); );
// The most recent visit that actually happened. `findForApplication` orders
// newest first, so the first COMPLETED row is the one that decides approval —
// a re-inspection that passed supersedes the failure before it.
const latestConductedInspection = inspections.find(
(i) => i.status === 'COMPLETED',
);
const latestInspectionPassed = latestConductedInspection
? latestConductedInspection.result === 'PASSED'
: null;
// A vessel registration's visit is pass/fail per area, nothing in between:
// there is no "fix and come back" state on a vessel, so the result form
// offers only those two, and the visit can only be recorded as PASSED when
// every area passed (US-VES-007).
const strictInspection = requiresPassedInspection(
loadedApp?.licenseType?.key,
);
const checklistOptions = [
{ value: "PASS", label: t("review.checkPass", "Pass") },
...(strictInspection
? []
: [
{
value: "NEEDS_CORRECTION",
label: t("review.checkFix", "Fix"),
},
]),
{ value: "FAIL", label: t("review.checkFail", "Fail") },
];
const allChecklistPassed = INSPECTION_CHECKLIST_ITEMS.every(
(item) => (checklist[item.key] ?? "PASS") === "PASS",
);
const passBlocked = strictInspection && !allChecklistPassed;
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } = const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
useGetAttachmentsQuery( useGetAttachmentsQuery(
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' }, { ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
@@ -384,6 +421,100 @@ export function LicenseReviewPage() {
[flags, data?.staff, t, localized, roleNameByKey], [flags, data?.staff, t, localized, roleNameByKey],
); );
/**
* What the applicant actually changed when they sent the file back.
*
* A resubmission used to arrive as a bare "round N" badge, which told the
* officer that something had been corrected but not what — so re-review meant
* re-reading the whole application. `valueChangedAt` is stamped by the edit
* itself, so these are the items whose data really moved; the ones the
* applicant ticked off without touching are listed separately, because they
* are the likeliest reason the file comes back a third time.
*
* Scoped to the current round: `requestAdjustment` increments the counter and
* `resubmit` does not, so the open round is the one just answered.
*/
/**
* A remark target as the officer reads it: section title, document name, or
* the staff member's role and name. The wire carries only the key — and for
* a staff remark that key is an ApplicationStaff uuid — so every place that
* shows a remark (the corrections panel, the activity rail) goes through this.
*/
const remarkLabel = useMemo(() => {
const sectionTitles = new Map(
(requirements?.licenseType.formSchema.sections ?? []).map((section) => [
section.key,
localized(section.title),
]),
);
const documentNames = new Map(
(requirements?.documentRequirements ?? []).map((requirement) => [
requirement.key,
localized(requirement.name),
]),
);
return (remark: ApplicationRemark): string => {
if (remark.targetType === "FORM_SECTION") {
return sectionTitles.get(remark.targetKey) || humaniseKey(remark.targetKey);
}
if (remark.targetType === "DOCUMENT") {
return documentNames.get(remark.targetKey) || humaniseKey(remark.targetKey);
}
const member = data?.staff.find((s) => s.id === remark.targetKey);
return member
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey}${member.fullName}`
: t("review.staffMember", "Staff member");
};
}, [data?.staff, requirements, localized, roleNameByKey, t]);
const correction = useMemo(() => {
const round = data?.application.adjustmentRound ?? 0;
if (!data || round === 0) {
return {
round,
corrected: [],
untouched: [],
sectionKeys: new Set<string>(),
documentKeys: new Set<string>(),
};
}
const roundRemarks = (data.remarks ?? []).filter(
(r) => r.roundNumber === round,
);
const item = (remark: ApplicationRemark) => ({
key: remark.id,
label: remarkLabel(remark),
remark: remark.remark,
changedAt: remark.valueChangedAt ?? remark.resolvedAt ?? remark.createdAt,
});
const changed = roundRemarks.filter((r) => r.valueChangedAt);
return {
round,
corrected: changed.map(item),
// Only once the file is actually back. While it is still with the
// applicant the round is being worked on, and calling a not-yet-edited
// item "marked done but left unchanged" would be an accusation about
// work still in progress.
untouched:
data.application.status === "RESUBMIT_REQUIRED"
? []
: roundRemarks
.filter((r) => !r.valueChangedAt && r.isResolved)
.map(item),
// Keyed per target type: a section and a document may legitimately
// share a key, and a badge on the wrong one would misdirect the officer.
sectionKeys: new Set(
changed.filter((r) => r.targetType === "FORM_SECTION").map((r) => r.targetKey),
),
documentKeys: new Set(
changed.filter((r) => r.targetType === "DOCUMENT").map((r) => r.targetKey),
),
};
}, [data, remarkLabel]);
const actions = useMemo(() => { const actions = useMemo(() => {
if (!data) return []; if (!data) return [];
return resolveActions({ return resolveActions({
@@ -393,6 +524,7 @@ export function LicenseReviewPage() {
flaggedCount: flagged.length, flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection), hasPendingInspection: Boolean(pendingInspection),
inspectionNotYetDue, inspectionNotYetDue,
latestInspectionPassed,
allDocumentsAccepted, allDocumentsAccepted,
reasons: { reasons: {
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'), wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
@@ -401,6 +533,10 @@ export function LicenseReviewPage() {
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'), needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'), needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'), needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
inspectionFailed: t(
'review.disabled.inspectionFailed',
'The inspection failed — a re-inspection must pass before approval',
),
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', { inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
date: pendingInspection?.scheduledDate date: pendingInspection?.scheduledDate
? showDate(pendingInspection.scheduledDate) ? showDate(pendingInspection.scheduledDate)
@@ -422,7 +558,19 @@ export function LicenseReviewPage() {
}), }),
}, },
}); });
}, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]); }, [
data,
currentUserId,
can,
flagged.length,
pendingInspection,
inspectionNotYetDue,
latestInspectionPassed,
allDocumentsAccepted,
documentProgress,
showDate,
t,
]);
// Location answers are tree ids. The picker the applicant used resolves them // Location answers are tree ids. The picker the applicant used resolves them
// client-side from the same list, so the reviewer reads the place rather than // client-side from the same list, so the reviewer reads the place rather than
@@ -902,9 +1050,8 @@ export function LicenseReviewPage() {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
onClick={() => navigate(`/licence-review/${related.id}`)} onClick={() => navigate(`/licence-review/${related.id}`)}
> >
{related.licenseType?.key === "SEAMAN_BOOK" {localized(related.licenseType?.name) ||
? "Seaman Book" related.licenseType?.key}{" "}
: "BTC"}{" "}
· {related.applicationNumber} ·{" "} · {related.applicationNumber} ·{" "}
{STATUS_LABELS[related.status]} {STATUS_LABELS[related.status]}
</Badge> </Badge>
@@ -935,6 +1082,12 @@ export function LicenseReviewPage() {
</Group> </Group>
</Group> </Group>
<CorrectedItemsPanel
round={correction.round}
items={correction.corrected}
untouched={correction.untouched}
/>
<Grid> <Grid>
{/* Zone 1 — sticky summary rail. */} {/* Zone 1 — sticky summary rail. */}
<Grid.Col span={{ base: 12, md: 3 }}> <Grid.Col span={{ base: 12, md: 3 }}>
@@ -1130,6 +1283,7 @@ export function LicenseReviewPage() {
formData={app.formData ?? {}} formData={app.formData ?? {}}
configSections={configSections} configSections={configSections}
currency={app.feeCurrency ?? undefined} currency={app.feeCurrency ?? undefined}
correctedKeys={correction.sectionKeys}
flags={flags} flags={flags}
onToggleFlag={(sectionKey) => onToggleFlag={(sectionKey) =>
toggleFlag("FORM_SECTION", sectionKey) toggleFlag("FORM_SECTION", sectionKey)
@@ -1205,6 +1359,7 @@ export function LicenseReviewPage() {
attachments={data.attachments} attachments={data.attachments}
requirements={requirements?.documentRequirements ?? []} requirements={requirements?.documentRequirements ?? []}
formData={app.formData ?? {}} formData={app.formData ?? {}}
correctedKeys={correction.documentKeys}
flags={documentFlags} flags={documentFlags}
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)} onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
onFlagRemark={(key, remark) => onFlagRemark={(key, remark) =>
@@ -1330,7 +1485,8 @@ export function LicenseReviewPage() {
{/* Page-level, not inside the inspection tab: a license type {/* Page-level, not inside the inspection tab: a license type
configured without an inspection detail section must still show configured without an inspection detail section must still show
why approval is blocked if it ever lands here. */} why approval is blocked if it ever lands here. */}
{status === "INSPECTION_FAILED" && ( {(status === "INSPECTION_FAILED" ||
latestInspectionPassed === false) && (
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}> <Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
{t( {t(
"review.inspectionFailedBlocked", "review.inspectionFailedBlocked",
@@ -1358,7 +1514,7 @@ export function LicenseReviewPage() {
{/* Zone 3 — activity and audit trail. */} {/* Zone 3 — activity and audit trail. */}
{railOpen && ( {railOpen && (
<Grid.Col span={{ base: 12, md: 3 }}> <Grid.Col span={{ base: 12, md: 3 }}>
<ActivityRail detail={data} /> <ActivityRail detail={data} remarkLabel={remarkLabel} />
</Grid.Col> </Grid.Col>
)} )}
</Grid> </Grid>
@@ -1563,17 +1719,18 @@ export function LicenseReviewPage() {
[item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION", [item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION",
})) }))
} }
data={[ data={checklistOptions}
{ value: "PASS", label: t("review.checkPass", "Pass") },
{
value: "NEEDS_CORRECTION",
label: t("review.checkFix", "Fix"),
},
{ value: "FAIL", label: t("review.checkFail", "Fail") },
]}
/> />
</Group> </Group>
))} ))}
{passBlocked && (
<Text size="xs" c="dimmed">
{t(
"review.checklistMustPass",
"Every item must pass before the inspection can be recorded as passed.",
)}
</Text>
)}
</Stack> </Stack>
<Textarea <Textarea
label={t("review.findings", "Findings")} label={t("review.findings", "Findings")}
@@ -1653,7 +1810,12 @@ export function LicenseReviewPage() {
variant="light" variant="light"
color="teal" color="teal"
size="lg" size="lg"
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue} disabled={
!findings.trim() ||
!pendingInspection ||
inspectionNotYetDue ||
passBlocked
}
aria-label={t("review.passed", "Passed")} aria-label={t("review.passed", "Passed")}
onClick={() => onClick={() =>
run( run(
@@ -1848,4 +2010,16 @@ function SummaryRow({
); );
} }
/**
* A camelCase or snake_case key as a heading.
*
* Only reached when the licence type's config has no label for the key — a
* section the schema has since dropped, say. Better than printing
* `vesselParticulars` at an officer.
*/
function humaniseKey(key: string): string {
const spaced = key.replace(/([A-Z])/g, " $1").replace(/[_-]+/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
}
export default LicenseReviewPage; export default LicenseReviewPage;

View File

@@ -10,6 +10,7 @@ import {
SEAFARER_DOCUMENT_STATUS_LABELS, SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage, extractErrorMessage,
useConfirmSeafarerDocumentPaymentMutation, useConfirmSeafarerDocumentPaymentMutation,
useGetLicenseTypesQuery,
useGetSeafarerDocumentReviewQuery, useGetSeafarerDocumentReviewQuery,
useIssueSeafarerDocumentMutation, useIssueSeafarerDocumentMutation,
useLazyGetSeafarerDocumentReviewDownloadQuery, useLazyGetSeafarerDocumentReviewDownloadQuery,
@@ -60,6 +61,9 @@ export function SeafarerDocumentReviewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id }); const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
// The document's policy lives on its licence-type row (the kind is the
// type's key), as configured on the Behaviour tab.
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation(); const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation(); const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
@@ -90,6 +94,11 @@ export function SeafarerDocumentReviewPage() {
} }
const { document, applicant, payment } = data; const { document, applicant, payment } = data;
// Collected in person unless the type says otherwise: then payment
// confirmation issues it and there is nothing to schedule.
const collectedInPerson =
licenseTypes?.items.find((type) => type.key === document.kind)
?.requiresIssuanceScheduling ?? true;
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind]; const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status); const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
@@ -156,12 +165,13 @@ export function SeafarerDocumentReviewPage() {
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )}
{document.status === 'PAYMENT_CONFIRMED' && ( {document.status === 'PAYMENT_CONFIRMED' && collectedInPerson && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button> <Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
</RequirePermission> </RequirePermission>
)} )}
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && ( {(document.status === 'SCHEDULED' ||
(document.status === 'PAYMENT_CONFIRMED' && !collectedInPerson)) && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly> <RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}> <Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
Issue Issue

View File

@@ -104,10 +104,7 @@ const UM_CONFIG: DesignConfig = {
const UM_RUNTIME = { const UM_RUNTIME = {
basename: "/um", basename: "/um",
// Keep the embedded IAM module on the same API as the backoffice client. // Keep the embedded IAM module on the same API as the backoffice client.
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui apiUrl: import.meta.env.VITE_BASE_API_URL ?? "https://ema-api-dev.triaplc.com/api",
// fall back to its remote development server, where the local JWT is
// rejected and the module redirects to its login page.
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
}; };
const buttonStyle: React.CSSProperties = { const buttonStyle: React.CSSProperties = {

View File

@@ -726,11 +726,19 @@ export const am: Translations = {
descEnPlaceholder: "የእንግሊዝኛ መግለጫ", descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
descAm: "መግለጫ (አማርኛ)", descAm: "መግለጫ (አማርኛ)",
descAmPlaceholder: "የአማርኛ መግለጫ", descAmPlaceholder: "የአማርኛ መግለጫ",
rankKey: "የSTCW ማዕረግ (ለፈተና መርሐግብር)",
rankKeyHint: "ይህ የምስክር ወረቀት የሚፈተነው የCoC/CoP መሰላል አካል ካልሆነ ባዶ ይተዉት።",
rankKeyPlaceholder: "ለተወሰነ ማዕረግ አይደለም",
isActive: "ንቁ",
isActiveHint: "ንቁ ያልሆኑ የምስክር ወረቀቶች በነባር ፈተናዎች ላይ ይቆያሉ፤ ለአዲስ ፈተናዎች ግን አይቀርቡም።",
}, },
status: { status: {
active: "ንቁ", active: "ንቁ",
inactive: "እንቅስቃሴ የሌለ", inactive: "እንቅስቃሴ የሌለ",
}, },
validation: {
nameRequired: "የእንግሊዝኛ እና የአማርኛ ስሞች ሁለቱም ያስፈልጋሉ",
},
}, },
result: { result: {
@@ -1005,6 +1013,88 @@ export const am: Translations = {
configuration: { configuration: {
title: "ውቅረት", title: "ውቅረት",
licenseTypesTab: "የፈቃድ አይነቶች",
licenseTypes: {
add: "የፈቃድ ዓይነት ያክሉ",
edit: "የፈቃድ ዓይነት አስተካክል",
key: "ቁልፍ",
keyHint:
"ቋሚ መለያ፣ ለምሳሌ CUSTOMS_BROKER። ማመልከቻዎች ከጠቀሱት በኋላ ሊቀየር አይችልም።",
keyInvalid: "አቢይ ፊደላት፣ አሃዞችና ከስር መስመር፣ ከ3 እስከ 64 ቁምፊዎች",
category: "ምድብ",
categoryHint:
"እነማን ማመልከት እንደሚችሉና የትኞቹ የሹመት ደረጃዎች እርምጃ መውሰድ እንደሚችሉ ይወስናል።",
familyKind: "ዓይነት",
familyKindHint:
"ፈቃድ፣ የምስክር ወረቀት ወይም ሰነድ። አጠቃቀሙን፣ የአመልካች ካታሎጉንና ወረፋው ድርጅት እንደሚያሳይ ይወስናል።",
prefix: "የምስክር ወረቀት ቅድመ ቅጥያ",
prefixHint:
"የእያንዳንዱ ማመልከቻና የምስክር ወረቀት ቁጥር መጀመሪያ፣ ለምሳሌ CB → CB-2026-000042።",
prefixInvalid: "ያስፈልጋል፣ ቢበዛ 12 ቁምፊዎች",
status: "ሁኔታ",
active: "ንቁ",
inactive: "እንቅስቃሴ የሌለ",
isActive: "ለአመልካቾች ይታያል",
isActiveHint: "ቅጹና የሰነድ መስፈርቶቹ እስኪዘጋጁ ድረስ አጥፍተው ይተዉት።",
activate: "አንቃ",
deactivate: "አሰናክል",
activateTitle: "የፈቃድ ዓይነት አንቃ",
deactivateTitle: "የፈቃድ ዓይነት አሰናክል",
activateText: "{{name}} ለአመልካቾች እንደገና ይቀርባል።",
deactivateText:
"{{name}} ከአመልካች ካታሎግ ይጠፋል። በሂደት ላይ ያሉ ማመልከቻዎች ሳይነኩ ይቀጥላሉ።",
activated: "የፈቃድ ዓይነቱ አሁን ማመልከቻዎችን ይቀበላል",
deactivated: "የፈቃድ ዓይነቱ ለአዲስ ማመልከቻዎች ተዘግቷል",
noPermission: "የፈቃድ ዓይነት ለመፍጠር ፈቃድ የለዎትም።",
nextStepsTitle: "ዓይነቱን ከፈጠሩ በኋላ",
nextSteps:
"ቅጹን፣ የሰነድ መስፈርቶቹንና ባህሪውን በምስክር ወረቀት መስፈርቶች፣ ክፍያዎቹን በክፍያ ውቅረት፣ የምስክር ወረቀት ንድፉን ደግሞ በንድፍ ሰሪው ያዘጋጁ — ከዚያ እዚህ ንቁ ያድርጉት።",
empty: "እስካሁን ምንም የፈቃድ አይነቶች የሉም",
notice:
"አዲስ አይነት ባዶ ሆኖ ይጀምራል። ከፈጠሩ በኋላ ቅጹንና የሰነድ መስፈርቶቹን በምስክር ወረቀት መስፈርቶች ስር፣ ክፍያዎቹን ደግሞ በክፍያ ውቅረት ስር ያዘጋጁ።",
goToRequirements: "የምስክር ወረቀት መስፈርቶች",
goToFees: "የክፍያ ውቅረት",
created: "የፈቃድ አይነት ተፈጥሯል። በመቀጠል ቅጹን፣ ሰነዶቹንና ክፍያዎቹን ያዘጋጁ።",
toggleAria: "ይህ የፈቃድ አይነት ማመልከቻ መቀበል አለመቀበሉን ይቀያይሩ",
familyHint: "የትኛው ክፍል እንደሚያስተዳድረው እና በየትኛው የፖርታል ካታሎግ እንደሚታይ ይወስናል።",
fee: "የአዲስ ማመልከቻ ክፍያ",
feeHint: "ክፍያ ከሌለው ባዶ ይተዉት።",
currency: "ምንዛሪ",
validity: "የአገልግሎት ጊዜ (በወራት)",
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
renewalEnabled: "ሊታደስ የሚችል",
inspectionRequired: "ምርመራ ያስፈልጋል",
columns: {
category: "ምድብ",
family: "ቤተሰብ",
prefix: "ቅድመ ቅጥያ",
status: "ሁኔታ",
actions: "እርምጃዎች",
},
family: {
LOGISTICS_LICENSE: "የሎጂስቲክስ ፈቃድ",
CERTIFICATE: "የባህርተኛ የምስክር ወረቀት",
DOCUMENT: "የማንነት / ህጋዊ ሰነድ",
},
service: {
LICENSE: "ፈቃድ",
REGISTRATION: "ምዝገባ",
},
workflow: {
STANDARD: "መደበኛ (ግምገማ → ምዘና → ምርመራ → ማጽደቅ)",
REGISTRATION: "ምዝገባ (ግምገማ → ማጽደቅ)",
},
validation: {
keyRequired: "ቁልፍ ያስፈልጋል",
keyTooLong: "ቁልፍ ከ64 ቁምፊዎች መብለጥ የለበትም",
keyFormat: "ፊደላት፣ አሃዞችና ከስር መስመር ብቻ ይጠቀሙ፣ ለምሳሌ PORT_AGENT",
keyTaken: "በዚህ ቁልፍ የፈቃድ አይነት ቀድሞ አለ",
prefixRequired: "የምስክር ወረቀት ቅድመ ቅጥያ ያስፈልጋል",
prefixTooLong: "ቅድመ ቅጥያ ከ12 ቁምፊዎች መብለጥ የለበትም",
currencyTooLong: "አጭር የምንዛሪ ኮድ ይጠቀሙ",
validityRange: "የአገልግሎት ጊዜ በ1 እና 240 ወራት መካከል መሆን አለበት",
},
},
personalDocumentsTab: "የግል ሰነዶች", personalDocumentsTab: "የግል ሰነዶች",
departments: "ክፍሎች", departments: "ክፍሎች",
professions: "ሙያዎች", professions: "ሙያዎች",
@@ -1040,6 +1130,7 @@ export const am: Translations = {
}, },
}, },
queue: { queue: {
title: "የፈቃድ ማመልከቻዎች", title: "የፈቃድ ማመልከቻዎች",
titleByFamily: "{{family}} ማመልከቻዎች", titleByFamily: "{{family}} ማመልከቻዎች",
@@ -1160,6 +1251,15 @@ export const am: Translations = {
deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።", deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።",
notificationPreview: "ለአመልካቹ የሚላክ መልእክት", notificationPreview: "ለአመልካቹ የሚላክ መልእክት",
notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።", notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።",
corrected: {
title_one: "በዙር {{round}} 1 ነጥብ ተስተካክሏል",
title_other: "በዙር {{round}} {{count}} ነጥቦች ተስተካክለዋል",
untouchedBadge_one: "1 አልተለወጠም",
untouchedBadge_other: "{{count}} አልተለወጡም",
untouched: "እንደተጠናቀቀ ምልክት ተደርጎ ግን አልተለወጠም — መጀመሪያ እነዚህን ይመልከቱ",
against: "የተጠየቀው፦ {{remark}}",
badge: "ተስተካክሏል",
},
needsCorrection: "ማስተካከያ ያስፈልገዋል", needsCorrection: "ማስተካከያ ያስፈልገዋል",
correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?", correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?",
verifiedCapital: "የተረጋገጠ ካፒታል (ብር)", verifiedCapital: "የተረጋገጠ ካፒታል (ብር)",
@@ -1242,6 +1342,7 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ", needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ", needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል", needsInspection: "የምርመራ ውጤት ያስፈልጋል",
inspectionFailed: "ምርመራው አልተሳካም — ከመጽደቁ በፊት ድጋሚ ምርመራ ማለፍ አለበት",
inspectionNotYetDue: inspectionNotYetDue:
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።", "ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
needsDocumentReviews: needsDocumentReviews:
@@ -1249,7 +1350,9 @@ export const am: Translations = {
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም", needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
}, },
inspectionFailedBlocked: inspectionFailedBlocked:
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።", "ምርመራው ስላልተሳካ ማጽደቅና ሰርተፍኬት መስጠት አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
checklistMustPass:
"ምርመራው እንደተሳካ ከመመዝገቡ በፊት ሁሉም ነጥቦች ማለፍ አለባቸው።",
reasons: { reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች", incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች", belowCapital: "ካፒታል ከሚያስፈልገው በታች",
@@ -1295,6 +1398,7 @@ export const am: Translations = {
officer: "ሹም", officer: "ሹም",
applicant: "አመልካች", applicant: "አመልካች",
remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል", remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል",
correctedTarget: "{{target}} ተስተካክሏል",
uploaded: "{{document}} ተጭኗል", uploaded: "{{document}} ተጭኗል",
}, },
documents: { documents: {
@@ -1657,6 +1761,21 @@ export const am: Translations = {
maxFilesUnlimited: "ገደብ የለም", maxFilesUnlimited: "ገደብ የለም",
sortOrder: "የቅደም ተከተል ቁጥር", sortOrder: "የቅደም ተከተል ቁጥር",
when: "መቼ", when: "መቼ",
importPersonal: "ከግል ሰነዶች አስመጣ",
importPersonalDesc:
"ለዚህ የፈቃድ አይነት እንደ መስፈርት ለማከል በውቅር ገጽ ላይ ከተዋቀሩት የግል ሰነዶች ይምረጡ።",
importFromPersonal: "ከግል ሰነድ አስመጣ",
selectPersonalDoc: "ዝርዝሩን ለመቅዳት የግል ሰነድ ይምረጡ…",
importSelected: "የተመረጡትን አስመጣ ({{count}})",
importAction: "አስመጣ",
customizeAndAdd: "አስተካክለህ ጨምር",
alreadyAdded: "ቀደም ሲል ተጨምሯል",
noPersonalDocs: "በውቅር ውስጥ የተዘጋጀ የግል ሰነድ እስካሁን የለም።",
importedFromPersonal: "ከግል ሰነድ የመጣ: {{name}}",
importedHelper:
"ከግል ሰነዶች የመጣ። ቁልፉ ከአመልካቹ ሰነዶች ጋር ስለሚዛመድ ፋይሎች በቀጥታ ይገናኛሉ።",
importMode: "ለሚመጡ ሰነዶች የመስፈርት ሁነታ",
importSuccess: "{{count}} የግል ሰነድ(ዶች) ገብተዋል",
}, },
personal: { personal: {
title: "የግል ሰነዶች", title: "የግል ሰነዶች",

View File

@@ -718,6 +718,13 @@ export const en = {
description: 'Description', description: 'Description',
status: 'Status', status: 'Status',
}, },
status: {
active: 'Active',
inactive: 'Inactive',
},
validation: {
nameRequired: 'Both English and Amharic names are required',
},
form: { form: {
nameEn: 'Name (English)', nameEn: 'Name (English)',
nameEnPlaceholder: 'Certificate name in English', nameEnPlaceholder: 'Certificate name in English',
@@ -727,10 +734,11 @@ export const en = {
descEnPlaceholder: 'English description', descEnPlaceholder: 'English description',
descAm: 'Description (Amharic)', descAm: 'Description (Amharic)',
descAmPlaceholder: 'የአማርኛ መግለጫ', descAmPlaceholder: 'የአማርኛ መግለጫ',
}, rankKey: 'STCW rank (for exam scheduling)',
status: { rankKeyHint: 'Leave blank if this certification is not part of the examined CoC/CoP ladder.',
active: 'Active', rankKeyPlaceholder: 'Not rank-specific',
inactive: 'Inactive', isActive: 'Active',
isActiveHint: 'Inactive certifications stay on existing exams but are not offered for new ones.',
}, },
}, },
@@ -1010,6 +1018,89 @@ export const en = {
configuration: { configuration: {
title: 'Configuration', title: 'Configuration',
licenseTypesTab: 'Licence Types',
licenseTypes: {
add: 'Add licence type',
edit: 'Edit licence type',
key: 'Key',
keyHint:
'Permanent identifier, e.g. CUSTOMS_BROKER. Cannot be changed once applications reference it.',
keyInvalid: 'Upper-case letters, digits and underscores, 364 characters',
category: 'Category',
categoryHint:
'Decides which applicants may apply and which officer positions can act on it.',
familyKind: 'Kind',
familyKindHint:
'Licence, certificate or document. Drives the wording, the applicant catalogue and whether the queue shows a company.',
prefix: 'Certificate prefix',
prefixHint:
'Front of every application and certificate number, e.g. CB → CB-2026-000042.',
prefixInvalid: 'Required, at most 12 characters',
status: 'Status',
active: 'Active',
inactive: 'Inactive',
isActive: 'Visible to applicants',
isActiveHint:
'Leave off until the form and document requirements are configured.',
activate: 'Activate',
deactivate: 'Deactivate',
activateTitle: 'Activate licence type',
deactivateTitle: 'Deactivate licence type',
activateText: '{{name}} will be offered to applicants again.',
deactivateText:
'{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.',
activated: 'Licence type is now accepting applications',
deactivated: 'Licence type is closed to new applications',
noPermission: 'You do not have permission to create licence types.',
nextStepsTitle: 'After creating a type',
nextSteps:
'Configure its form, document requirements and behaviour on Certificate requirements, its fees on Payment configuration, and its certificate design in the designer — then switch it active here.',
empty: 'No licence types yet',
notice:
'A new type starts empty. After creating it, set up its form and document slots under Certificate Requirements, and its fees under Payment Configuration.',
goToRequirements: 'Certificate requirements',
goToFees: 'Payment configuration',
created: 'Licence type created. Configure its form, documents and fees next.',
toggleAria: 'Toggle whether this licence type accepts applications',
familyHint: 'Decides which desk owns it and which portal catalogue lists it.',
fee: 'New application fee',
feeHint: 'Leave blank for no charge.',
currency: 'Currency',
validity: 'Validity (months)',
issuesCertificate: 'Issues a certificate',
renewalEnabled: 'Renewable',
inspectionRequired: 'Inspection required',
columns: {
category: 'Category',
family: 'Family',
prefix: 'Prefix',
status: 'Status',
actions: 'Actions',
},
family: {
LOGISTICS_LICENSE: 'Logistics licence',
CERTIFICATE: 'Seafarer certificate',
DOCUMENT: 'Identity / statutory document',
},
service: {
LICENSE: 'Licence',
REGISTRATION: 'Registration',
},
workflow: {
STANDARD: 'Standard (review → evaluation → inspection → approval)',
REGISTRATION: 'Registration (review → approval)',
},
validation: {
keyRequired: 'Key is required',
keyTooLong: 'Key must be at most 64 characters',
keyFormat: 'Use letters, digits and underscores, e.g. PORT_AGENT',
keyTaken: 'A licence type with this key already exists',
prefixRequired: 'Certificate prefix is required',
prefixTooLong: 'Prefix must be at most 12 characters',
currencyTooLong: 'Use a short currency code',
validityRange: 'Validity must be between 1 and 240 months',
},
},
personalDocumentsTab: 'Personal Documents', personalDocumentsTab: 'Personal Documents',
departments: 'Departments', departments: 'Departments',
professions: 'Professions', professions: 'Professions',
@@ -1170,6 +1261,15 @@ export const en = {
deficienciesHint: 'Only the ticked items become editable for the applicant.', deficienciesHint: 'Only the ticked items become editable for the applicant.',
notificationPreview: 'Message to the applicant', notificationPreview: 'Message to the applicant',
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.', notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
corrected: {
title_one: '1 item corrected in round {{round}}',
title_other: '{{count}} items corrected in round {{round}}',
untouchedBadge_one: '1 unchanged',
untouchedBadge_other: '{{count}} unchanged',
untouched: 'Marked done but left unchanged — re-read these first',
against: 'Asked: {{remark}}',
badge: 'Corrected',
},
needsCorrection: 'Needs correction', needsCorrection: 'Needs correction',
correctionPlaceholder: 'What must the applicant correct?', correctionPlaceholder: 'What must the applicant correct?',
verifiedCapital: 'Verified capital (ETB)', verifiedCapital: 'Verified capital (ETB)',
@@ -1252,6 +1352,8 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction', needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first', needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result', needsInspection: 'Requires an inspection result',
inspectionFailed:
'The inspection failed — a re-inspection must pass before approval',
inspectionNotYetDue: inspectionNotYetDue:
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.', 'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
needsDocumentReviews: needsDocumentReviews:
@@ -1259,7 +1361,9 @@ export const en = {
needsDocumentsUploaded: 'No documents uploaded to review yet', needsDocumentsUploaded: 'No documents uploaded to review yet',
}, },
inspectionFailedBlocked: inspectionFailedBlocked:
'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.', 'Approval and certificate issuance are unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
checklistMustPass:
'Every item must pass before the inspection can be recorded as passed.',
reasons: { reasons: {
incompleteDocuments: 'Incomplete documents', incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum', belowCapital: 'Capital below the required minimum',
@@ -1302,6 +1406,7 @@ export const en = {
officer: 'Officer', officer: 'Officer',
applicant: 'Applicant', applicant: 'Applicant',
remarkOn: 'Correction requested on {{target}}', remarkOn: 'Correction requested on {{target}}',
correctedTarget: 'Corrected {{target}}',
uploaded: 'Uploaded {{document}}', uploaded: 'Uploaded {{document}}',
}, },
documents: { documents: {
@@ -1664,6 +1769,21 @@ export const en = {
maxFilesUnlimited: 'No limit', maxFilesUnlimited: 'No limit',
sortOrder: 'Sort order', sortOrder: 'Sort order',
when: 'when', when: 'when',
importPersonal: 'Import from personal documents',
importPersonalDesc:
'Select documents configured in Personal Documents to add as requirements for this licence type.',
importFromPersonal: 'Import from personal document',
selectPersonalDoc: 'Select a personal document to copy details…',
importSelected: 'Import selected ({{count}})',
importAction: 'Import',
customizeAndAdd: 'Customize & add',
alreadyAdded: 'Already added',
noPersonalDocs: 'No personal documents configured in Configuration yet.',
importedFromPersonal: 'Imported from personal document: {{name}}',
importedHelper:
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
importMode: 'Requirement mode for imported documents',
importSuccess: 'Imported {{count}} personal document(s)',
}, },
personal: { personal: {
title: 'Personal documents', title: 'Personal documents',

View File

@@ -14,8 +14,9 @@ export default defineConfig({
host: 'localhost', host: 'localhost',
proxy: { proxy: {
'/api': { '/api': {
target: 'https://ema-api-dev.triaplc.com', target: process.env.VITE_API_PROXY_TARGET || 'https://ema-api-dev.triaplc.com',
changeOrigin: true, changeOrigin: true,
secure: false,
}, },
}, },
}, },

View File

@@ -36,8 +36,10 @@ import {
useApiQuery, useApiQuery,
useBypassPaymentMutation, useBypassPaymentMutation,
useDiscardApplicationMutation, useDiscardApplicationMutation,
useGetLicenseTypesQuery,
useGetCertificateUrlMutation, useGetCertificateUrlMutation,
useGetPaymentCapabilitiesQuery, useGetPaymentCapabilitiesQuery,
type LicenseType,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { import {
useGetMySeaServiceRecordsQuery, useGetMySeaServiceRecordsQuery,
@@ -146,6 +148,45 @@ function formatDate(value: string | null | undefined): string {
}); });
} }
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();
if (!token) throw new Error('No auth token found');
const res = await fetch(
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
return res.blob();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* A licence type's validity the way a certificate would print it: days win
* over months, whole years read as years.
*/
function validityLabel(type: LicenseType): string {
if (type.validityDays) {
return `${type.validityDays} ${type.validityDays === 1 ? 'day' : 'days'}`;
}
const months = type.validityMonths || 12;
if (months % 12 === 0) {
const years = months / 12;
return `${years} ${years === 1 ? 'year' : 'years'}`;
}
return `${months} ${months === 1 ? 'month' : 'months'}`;
}
export function CertificatesPage() { export function CertificatesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@@ -234,6 +275,33 @@ export function CertificatesPage() {
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery(); const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery(); const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
// The certificate types on offer and how long each is valid come from the
// licence-type rows the backoffice configures (certificate category and
// validity on the Behaviour tab), not from a list kept here.
const { data: licenseTypes } = useGetLicenseTypesQuery();
const certificateTypes = (licenseTypes?.items ?? []).filter(
(type) =>
type.isActive !== false &&
(type.certificateCategory === 'COC' || type.certificateCategory === 'COP'),
);
const applyTargets =
certificateTypes.length > 0
? certificateTypes.map((type) => ({
key: type.key,
label: type.certificateCategory === 'COC' ? 'CoC' : 'CoP',
variant: (type.certificateCategory === 'COC' ? 'filled' : 'light') as 'filled' | 'light',
}))
: // Before the catalogue loads, or on a server that has not seeded it.
[
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
];
const validityTerms = [...new Set(certificateTypes.map(validityLabel))];
const validityDescription =
validityTerms.length > 0
? `CoC/CoP certificates are valid for ${validityTerms.join(' or ')} and must be revalidated before expiry.`
: 'CoC/CoP certificates are valid for a fixed term and must be revalidated before expiry.';
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE'; const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED'); const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED'); const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
@@ -296,10 +364,7 @@ export function CertificatesPage() {
disabled, so the reason still shows on hover. */} disabled, so the reason still shows on hover. */}
<span> <span>
<Group gap="xs"> <Group gap="xs">
{([ {applyTargets.map(({ key, label, variant }) => {
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
]).map(({ key, label, variant }) => {
const hasDraft = draftTypeKeys.has(key); const hasDraft = draftTypeKeys.has(key);
return ( return (
<Button <Button
@@ -337,7 +402,7 @@ export function CertificatesPage() {
{[ {[
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' }, { icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' }, { icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' }, { icon: IconClock, color: 'orange', title: 'Validity', desc: validityDescription },
].map(({ icon: Icon, color, title, desc }) => ( ].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm"> <Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}> <Group gap="xs" mb={4}>

View File

@@ -0,0 +1,361 @@
import { useState } from 'react';
import {
Box,
Card,
Center,
ColorSwatch,
Flex,
Group,
SegmentedControl,
Stack,
Text,
ThemeIcon,
useComputedColorScheme,
} from '@mantine/core';
import { IconCategory, IconChartPie, IconFileAnalytics, IconTrendingUp } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
export interface StatusDistributionItem {
name: string;
value: number;
color: string;
}
export interface MonthlyTrendItem {
month: string;
year: number;
submitted: number;
approved: number;
}
export interface CategoryBreakdownItem {
category: string;
count: number;
}
export interface DashboardChartsProps {
statusDistribution: StatusDistributionItem[];
monthlyTrend: MonthlyTrendItem[];
categoryBreakdown?: CategoryBreakdownItem[];
totalApplications: number;
}
const CATEGORY_COLORS = ['#2a59ac', '#1bb497', '#4c6ef5', '#fab005', '#e64980', '#12b886'];
export function DashboardCharts({
monthlyTrend,
categoryBreakdown = [],
totalApplications,
}: DashboardChartsProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<'timeline' | 'category'>('timeline');
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const tooltipStyle = {
backgroundColor: isDark ? 'var(--mantine-color-dark-7, #1a1b1e)' : '#ffffff',
border: isDark
? '1px solid var(--mantine-color-dark-4, #373a40)'
: '1px solid var(--mantine-color-gray-2, #e9ecef)',
borderRadius: '8px',
boxShadow: isDark
? '0 4px 16px rgba(0, 0, 0, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.08)',
padding: '8px 12px',
fontSize: '12px',
color: isDark ? '#c1c2c5' : '#212529',
};
const gridColor = isDark ? 'var(--mantine-color-dark-5, #2c2e33)' : 'var(--mantine-color-gray-2, #e9ecef)';
const tickColor = isDark ? '#909296' : '#868e96';
return (
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="emaPrimary" size="md" radius="md">
{viewMode === 'timeline' ? <IconTrendingUp size={18} /> : <IconCategory size={18} />}
</ThemeIcon>
<Box>
<Text fw={700} size="md">
{viewMode === 'timeline'
? t('dashboard.charts.activityTitle')
: t('dashboard.charts.categoryTitle')}
</Text>
<Text size="xs" c="dimmed">
{viewMode === 'timeline'
? t('dashboard.charts.activitySubtitle')
: t('dashboard.charts.categoryCount')}
</Text>
</Box>
</Group>
{totalApplications > 0 && (
<SegmentedControl
size="xs"
radius="md"
value={viewMode}
onChange={(val) => setViewMode(val as 'timeline' | 'category')}
data={[
{ label: t('dashboard.charts.viewTimeline'), value: 'timeline' },
{ label: t('dashboard.charts.viewCategory'), value: 'category' },
]}
/>
)}
</Group>
<Box style={{ height: 220, width: '100%' }}>
{totalApplications === 0 ? (
<Center h={200}>
<Stack align="center" gap={4}>
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconFileAnalytics size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t('dashboard.charts.noData')}
</Text>
</Stack>
</Center>
) : viewMode === 'timeline' ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={monthlyTrend} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="areaSubmissions" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
stopOpacity={isDark ? 0.5 : 0.35}
/>
<stop
offset="95%"
stopColor={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
stopOpacity={0.0}
/>
</linearGradient>
<linearGradient id="areaApproved" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
stopOpacity={isDark ? 0.5 : 0.35}
/>
<stop
offset="95%"
stopColor={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
stopOpacity={0.0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridColor} />
<XAxis
dataKey="month"
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: unknown, name: unknown) => [
value as string | number,
name === 'submitted'
? t('dashboard.charts.submitted')
: t('dashboard.charts.approved'),
]}
/>
<Area
type="monotone"
dataKey="submitted"
stroke={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
strokeWidth={2}
fillOpacity={1}
fill="url(#areaSubmissions)"
name="submitted"
/>
<Area
type="monotone"
dataKey="approved"
stroke={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
strokeWidth={2}
fillOpacity={1}
fill="url(#areaApproved)"
name="approved"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={categoryBreakdown} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridColor} />
<XAxis
dataKey="category"
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: unknown) => [value as string | number, t('dashboard.charts.categoryCount')]}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
{categoryBreakdown.map((_entry, index) => (
<Cell
key={`bar-${index}`}
fill={isDark ? '#4dabf7' : CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Box>
</Card>
);
}
export function StatusDonutCard({
statusDistribution,
totalApplications,
}: {
statusDistribution: StatusDistributionItem[];
totalApplications: number;
}) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const nonZeroSlices = statusDistribution.filter((item) => item.value > 0);
const pieData =
nonZeroSlices.length > 0
? nonZeroSlices
: [
{
name: t('dashboard.charts.total'),
value: 1,
color: isDark ? 'var(--mantine-color-dark-4, #373a40)' : '#e9ecef',
},
];
const tooltipStyle = {
backgroundColor: isDark ? 'var(--mantine-color-dark-7, #1a1b1e)' : '#ffffff',
border: isDark
? '1px solid var(--mantine-color-dark-4, #373a40)'
: '1px solid var(--mantine-color-gray-2, #e9ecef)',
borderRadius: '8px',
boxShadow: isDark
? '0 4px 16px rgba(0, 0, 0, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.08)',
padding: '8px 12px',
fontSize: '12px',
color: isDark ? '#c1c2c5' : '#212529',
};
return (
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="sm">
<Text fw={700} size="md">
{t('dashboard.charts.statusTitle')}
</Text>
<ThemeIcon variant="light" color="emaPrimary" size="sm" radius="md">
<IconChartPie size={14} />
</ThemeIcon>
</Group>
<Flex
direction={{ base: 'column', xs: 'row', sm: 'row' }}
align="center"
justify="center"
gap="md"
py="xs"
>
<Box style={{ position: 'relative', width: 140, height: 140 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
innerRadius={46}
outerRadius={65}
paddingAngle={nonZeroSlices.length > 1 ? 3 : 0}
dataKey="value"
stroke="none"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} />
</PieChart>
</ResponsiveContainer>
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
pointerEvents: 'none',
}}
>
<Text fw={700} fz={22} lh={1.1} c={isDark ? 'white' : 'dark'}>
{totalApplications}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.total')}
</Text>
</Box>
</Box>
<Stack gap={10} style={{ flex: 1, width: '100%' }}>
{statusDistribution.map((item) => {
const pct =
totalApplications > 0
? Math.round((item.value / totalApplications) * 100)
: 0;
return (
<Group key={item.name} justify="space-between" wrap="nowrap" gap="xs">
<Group gap={8} wrap="nowrap">
<ColorSwatch size={10} color={item.color} />
<Text size="xs" fw={500} c="dimmed">
{item.name}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={700}>
{item.value}
</Text>
<Text size="xs" c="dimmed" style={{ minWidth: 32, textAlign: 'right' }}>
{pct}%
</Text>
</Group>
</Group>
);
})}
</Stack>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,100 @@
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
IconAward,
IconCircleCheck,
IconClockHour4,
IconFolder,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export interface DashboardStatsProps {
activeLicenses: number;
pendingApplications: number;
approvedApplications: number;
documentsCount: number;
}
export function DashboardStats({
activeLicenses,
pendingApplications,
approvedApplications,
documentsCount,
}: DashboardStatsProps) {
const { t } = useTranslation();
const stats = [
{
title: t('dashboard.stats.activeLicenses'),
value: activeLicenses,
trend: activeLicenses > 0 ? t('dashboard.stats.allInGoodStanding') : '—',
icon: IconAward,
color: 'teal',
trendColor: 'teal',
},
{
title: t('dashboard.stats.pendingApplications'),
value: pendingApplications,
trend: pendingApplications > 0 ? t('dashboard.stats.inReview') : '0 pending',
icon: IconClockHour4,
color: 'orange',
trendColor: 'orange',
},
{
title: t('dashboard.stats.approved'),
value: approvedApplications,
trend: approvedApplications > 0 ? t('dashboard.stats.passRate') : '—',
icon: IconCircleCheck,
color: 'green',
trendColor: 'green',
},
{
title: t('dashboard.stats.documents'),
value: documentsCount,
trend: documentsCount > 0 ? t('dashboard.stats.upToDate') : '—',
icon: IconFolder,
color: 'emaPrimary',
trendColor: 'emaPrimary',
},
];
return (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{stats.map((stat) => {
const IconComponent = stat.icon;
return (
<Card
key={stat.title}
withBorder
radius="md"
padding="lg"
style={{
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={6}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: '0.5px' }}>
{stat.title}
</Text>
<Text fz={32} fw={700} lh={1.1} style={{ letterSpacing: '-0.5px' }}>
{stat.value}
</Text>
<Text size="xs" fw={600} c={stat.trendColor}>
{stat.trend}
</Text>
</Stack>
<ThemeIcon
size={48}
radius="md"
variant="light"
color={stat.color}
>
<IconComponent size={24} stroke={1.8} />
</ThemeIcon>
</Group>
</Card>
);
})}
</SimpleGrid>
);
}

View File

@@ -0,0 +1,258 @@
import {
Badge,
Box,
Button,
Divider,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
useComputedColorScheme,
} from '@mantine/core';
import {
IconAward,
IconCertificate,
IconClockHour4,
IconFileText,
IconPrinter,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { localized, STATUS_COLORS, STATUS_LABELS } from '@ema-platform/api';
export interface ExportStatementModalProps {
opened: boolean;
onClose: () => void;
displayName: string;
applications: LicenseApplication[];
licenses: IssuedLicense[];
documentsCount: number;
}
export function ExportStatementModal({
opened,
onClose,
displayName,
applications,
licenses,
documentsCount,
}: ExportStatementModalProps) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE');
const inProgressApps = applications.filter((a) => a.status !== 'DRAFT');
const todayStr = new Date().toLocaleDateString('en-GB', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
function handlePrint() {
window.print();
}
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
radius="md"
padding="xl"
title={
<Group gap="xs">
<ThemeIcon variant="light" color="emaPrimary" radius="md">
<IconCertificate size={20} />
</ThemeIcon>
<Text fw={700} size="md">
{t('dashboard.export.title')}
</Text>
</Group>
}
>
<Stack gap="lg" id="ema-export-statement-content">
{/* Statement Header */}
<Paper
p="md"
radius="md"
withBorder
style={{
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)',
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
{t('app.authority', 'Ethiopian Maritime Authority')}
</Text>
<Text fz={18} fw={700} mt={2}>
{displayName || t('dashboard.export.accountHolder')}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.export.subtitle')}
</Text>
</Box>
<Box style={{ textAlign: 'right' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.issueDate')}
</Text>
<Text size="sm" fw={600}>
{todayStr}
</Text>
<Badge variant="outline" color="teal" size="xs" mt={4}>
Electronic Copy
</Badge>
</Box>
</Group>
</Paper>
{/* Snapshot Summary Metrics */}
<SimpleGrid cols={{ base: 3 }} spacing="sm">
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.activeLicencesCount')}
</Text>
<Text fz={24} fw={700} c="teal">
{activeLicenses.length}
</Text>
</Paper>
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.pendingApplicationsCount')}
</Text>
<Text fz={24} fw={700} c="orange">
{inProgressApps.length}
</Text>
</Paper>
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.verifiedDocumentsCount')}
</Text>
<Text fz={24} fw={700} c="blue">
{documentsCount}
</Text>
</Paper>
</SimpleGrid>
{/* Active Licences Table */}
<Box>
<Text fw={700} size="sm" mb="xs">
{t('dashboard.sections.myLicences.title')} ({activeLicenses.length})
</Text>
{activeLicenses.length === 0 ? (
<Text size="xs" c="dimmed">
{t('applications.licences.empty')}
</Text>
) : (
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate #</Table.Th>
<Table.Th>Licence Type</Table.Th>
<Table.Th>Expiry Date</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{activeLicenses.map((lic) => (
<Table.Tr key={lic.id}>
<Table.Td fw={600}>{lic.certificateNumber}</Table.Td>
<Table.Td>{localized(lic.licenseType?.name) || '—'}</Table.Td>
<Table.Td>
{lic.expiryDate
? new Date(lic.expiryDate).toLocaleDateString('en-GB')
: '—'}
</Table.Td>
<Table.Td>
<Badge size="xs" color="teal" variant="light">
Active
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Box>
{/* Recent Applications Table */}
<Box>
<Text fw={700} size="sm" mb="xs">
{t('dashboard.sections.myApplications.title')} ({applications.slice(0, 5).length})
</Text>
{applications.length === 0 ? (
<Text size="xs" c="dimmed">
{t('dashboard.sections.myApplications.empty')}
</Text>
) : (
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Application #</Table.Th>
<Table.Th>Licence Type</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{applications.slice(0, 5).map((app) => (
<Table.Tr key={app.id}>
<Table.Td fw={600}>{app.applicationNumber}</Table.Td>
<Table.Td>{localized(app.licenseType?.name) || '—'}</Table.Td>
<Table.Td>
{app.createdAt
? new Date(app.createdAt).toLocaleDateString('en-GB')
: '—'}
</Table.Td>
<Table.Td>
<Badge
size="xs"
color={STATUS_COLORS[app.status] ?? 'gray'}
variant="light"
>
{STATUS_LABELS[app.status] ?? app.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Box>
<Divider />
<Text size="11px" c="dimmed" ta="center">
{t('dashboard.export.officialFooter')}
</Text>
{/* Modal Actions */}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('dashboard.export.close')}
</Button>
<Button
variant="filled"
color="emaPrimary"
leftSection={<IconPrinter size={16} />}
onClick={handlePrint}
>
{t('dashboard.export.printButton')}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,239 @@
import {
Badge,
Box,
Button,
Card,
Group,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
useComputedColorScheme,
} from '@mantine/core';
import {
IconArrowRight,
IconCheck,
IconCircleDot,
IconFolder,
IconRocket,
IconSparkles,
IconUserCheck,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export interface OnboardingChecklistCardProps {
displayName: string;
documentsCount: number;
onBrowseCatalogue: () => void;
}
export function OnboardingChecklistCard({
displayName,
documentsCount,
onBrowseCatalogue,
}: OnboardingChecklistCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const step1Complete = Boolean(displayName);
const step2Complete = documentsCount > 0;
const step3Complete = false;
const completedCount =
(step1Complete ? 1 : 0) + (step2Complete ? 1 : 0) + (step3Complete ? 1 : 0);
const progressPct = Math.round((completedCount / 3) * 100);
const steps = [
{
id: 1,
title: t('dashboard.onboarding.step1'),
desc: t('dashboard.onboarding.step1Desc'),
icon: IconUserCheck,
completed: step1Complete,
action: !step1Complete ? (
<Button
size="xs"
variant="light"
color="emaPrimary"
onClick={() => navigate('/profile')}
>
{t('common.continue')}
</Button>
) : null,
},
{
id: 2,
title: t('dashboard.onboarding.step2'),
desc: t('dashboard.onboarding.step2Desc'),
icon: IconFolder,
completed: step2Complete,
action: !step2Complete ? (
<Button
size="xs"
variant="light"
color="teal"
onClick={() => navigate('/documents')}
rightSection={<IconArrowRight size={14} />}
>
{t('dashboard.onboarding.viewVault')}
</Button>
) : null,
},
{
id: 3,
title: t('dashboard.onboarding.step3'),
desc: t('dashboard.onboarding.step3Desc'),
icon: IconRocket,
completed: step3Complete,
action: (
<Button
size="xs"
variant="filled"
color="emaPrimary"
onClick={onBrowseCatalogue}
rightSection={<IconArrowRight size={14} />}
>
{t('dashboard.onboarding.browseCatalogue')}
</Button>
),
},
];
return (
<Card
withBorder
radius="md"
padding="lg"
style={{
backgroundColor: isDark
? 'var(--mantine-color-dark-7, #1a1b1e)'
: 'var(--mantine-color-body, #ffffff)',
boxShadow: isDark
? '0 4px 20px rgba(0, 0, 0, 0.3)'
: '0 4px 16px rgba(42, 89, 172, 0.06)',
}}
>
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon
size={40}
radius="md"
variant="light"
color="emaPrimary"
>
<IconSparkles size={22} />
</ThemeIcon>
<Box>
<Title order={4} fw={700}>
{t('dashboard.onboarding.title')}
</Title>
<Text size="xs" c="dimmed">
{t('dashboard.onboarding.subtitle')}
</Text>
</Box>
</Group>
<Group gap="xs">
<Badge
variant="light"
color={completedCount === 3 ? 'green' : 'blue'}
size="md"
>
{t('dashboard.onboarding.progress', {
completed: completedCount,
total: 3,
})}
</Badge>
</Group>
</Group>
<Progress
value={progressPct}
color={completedCount === 3 ? 'green' : 'emaPrimary'}
size="sm"
radius="xl"
mb="lg"
/>
<Stack gap="sm">
{steps.map((step) => {
const StepIcon = step.icon;
return (
<Paper
key={step.id}
radius="md"
p="sm"
withBorder
style={{
backgroundColor: isDark
? 'var(--mantine-color-dark-6, #25262b)'
: step.completed
? 'var(--mantine-color-gray-0, #f8f9fa)'
: '#ffffff',
borderColor: step.completed
? 'transparent'
: isDark
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)',
transition: 'all 0.15s ease',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon
size={36}
radius="xl"
variant={step.completed ? 'filled' : 'light'}
color={step.completed ? 'teal' : 'gray'}
>
{step.completed ? (
<IconCheck size={18} stroke={2.5} />
) : (
<StepIcon size={18} />
)}
</ThemeIcon>
<Box>
<Group gap="xs">
<Text
size="sm"
fw={600}
style={{
textDecoration: step.completed ? 'line-through' : 'none',
opacity: step.completed ? 0.75 : 1,
}}
>
{step.title}
</Text>
{step.completed && (
<Badge size="xs" variant="dot" color="teal">
{t('dashboard.onboarding.completed')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{step.desc}
</Text>
</Box>
</Group>
{step.action && <Box>{step.action}</Box>}
</Group>
</Paper>
);
})}
</Stack>
</Card>
);
}
function Title({ order, fw, children }: { order: 1 | 2 | 3 | 4 | 5 | 6; fw: number; children: React.ReactNode }) {
return (
<Text fz={order === 4 ? 16 : 18} fw={fw} lh={1.2}>
{children}
</Text>
);
}

View File

@@ -0,0 +1,228 @@
import { useState } from 'react';
import {
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Stack,
Text,
ThemeIcon,
UnstyledButton,
useComputedColorScheme,
} from '@mantine/core';
import {
IconBell,
IconChevronRight,
IconHeadset,
IconLifebuoy,
IconMail,
IconMapPin,
IconPhone,
IconSquarePlus,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export interface QuickActionsCardProps {
onApplyClick?: () => void;
}
export function QuickActionsCard({ onApplyClick }: QuickActionsCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [supportOpened, setSupportOpened] = useState(false);
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const actions = [
{
key: 'apply',
title: t('dashboard.quickActionsList.applyLicense'),
description: t('dashboard.quickActionsList.applyLicenseDesc'),
icon: IconSquarePlus,
color: 'emaPrimary',
onClick: () => {
if (onApplyClick) {
onApplyClick();
} else {
const el = document.getElementById('license-catalogue-section');
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
} else {
navigate('/licensing/applications');
}
}
},
},
{
key: 'upload',
title: t('dashboard.quickActionsList.uploadDocument'),
description: t('dashboard.quickActionsList.uploadDocumentDesc'),
icon: IconUpload,
color: 'teal',
onClick: () => navigate('/documents'),
},
{
key: 'notifications',
title: t('dashboard.quickActionsList.notifications'),
description: t('dashboard.quickActionsList.notificationsDesc'),
icon: IconBell,
color: 'indigo',
onClick: () => navigate('/notifications'),
},
{
key: 'support',
title: t('dashboard.quickActionsList.support'),
description: t('dashboard.quickActionsList.supportDesc'),
icon: IconLifebuoy,
color: 'orange',
onClick: () => setSupportOpened(true),
},
];
return (
<>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="sm">
<Text fw={700} size="md">
{t('dashboard.quickActions')}
</Text>
</Group>
<Stack gap="xs">
{actions.map((item) => {
const IconComp = item.icon;
return (
<UnstyledButton
key={item.key}
onClick={item.onClick}
style={{
padding: '10px 12px',
borderRadius: '10px',
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)',
transition: 'background-color 0.15s ease, transform 0.1s ease',
border: isDark
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-2)',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={36}
radius="md"
variant="light"
color={item.color}
>
<IconComp size={18} stroke={1.8} />
</ThemeIcon>
<Box style={{ textAlign: 'left' }}>
<Text size="sm" fw={600} lh={1.2}>
{item.title}
</Text>
<Text size="xs" c="dimmed" lh={1.3}>
{item.description}
</Text>
</Box>
</Group>
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed, #868e96)"
/>
</Group>
</UnstyledButton>
);
})}
</Stack>
</Card>
<Modal
opened={supportOpened}
onClose={() => setSupportOpened(false)}
title={
<Group gap="xs">
<ThemeIcon variant="light" color="emaPrimary" radius="md">
<IconHeadset size={18} />
</ThemeIcon>
<Text fw={700}>{t('dashboard.supportModal.title')}</Text>
</Group>
}
radius="md"
centered
>
<Stack gap="md" py="xs">
<Text size="sm" c="dimmed">
{t('dashboard.supportModal.description')}
</Text>
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon variant="light" color="blue" radius="md">
<IconMail size={16} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.supportModal.emailLabel')}
</Text>
<Text size="sm" fw={600}>
{t('dashboard.supportModal.email')}
</Text>
</Box>
</Group>
<Divider />
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon variant="light" color="teal" radius="md">
<IconPhone size={16} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.supportModal.phoneLabel')}
</Text>
<Text size="sm" fw={600}>
{t('dashboard.supportModal.phone')}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.supportModal.hours')}
</Text>
</Box>
</Group>
<Divider />
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon variant="light" color="orange" radius="md">
<IconMapPin size={16} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.supportModal.officeLabel')}
</Text>
<Text size="sm">
{t('dashboard.supportModal.office')}
</Text>
</Box>
</Group>
</Stack>
</Paper>
<Button
variant="default"
fullWidth
onClick={() => setSupportOpened(false)}
>
{t('dashboard.supportModal.close')}
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -1,4 +1,4 @@
import { Badge, Progress, Text } from '@mantine/core'; import { Badge, Box, Progress, Stack, Text } from '@mantine/core';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { import {
@@ -9,6 +9,20 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import type { LicenseApplication } from '@ema-platform/api'; import type { LicenseApplication } from '@ema-platform/api';
function formatSubmittedDate(dateStr?: string | null): string {
if (!dateStr) return '—';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return '—';
}
}
export function dashboardApplicationColumns( export function dashboardApplicationColumns(
t: TFunction, t: TFunction,
): AdvancedColumn<LicenseApplication>[] { ): AdvancedColumn<LicenseApplication>[] {
@@ -16,40 +30,60 @@ export function dashboardApplicationColumns(
{ {
header: t('dashboard.table.application'), header: t('dashboard.table.application'),
cell: ({ row }) => ( cell: ({ row }) => (
<> <Box>
<Text size="sm" fw={600}> <Text size="sm" fw={600} lh={1.2}>
{row.original.applicationNumber} {row.original.applicationNumber}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed" mt={2}>
{row.original.companyName ?? '—'} {row.original.companyName ?? '—'}
</Text> </Text>
</> </Box>
), ),
}, },
{ {
header: t('applications.table.licence'), header: t('dashboard.table.type'),
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text> <Text size="sm" fw={500}>
{localized(row.original.licenseType?.name) || '—'}
</Text>
),
},
{
header: t('dashboard.table.submitted'),
cell: ({ row }) => (
<Text size="xs" c="dimmed" fw={500}>
{formatSubmittedDate(row.original.createdAt)}
</Text>
), ),
}, },
{ {
header: t('common.status'), header: t('common.status'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status]}> <Badge
{STATUS_LABELS[row.original.status]} variant="light"
color={STATUS_COLORS[row.original.status] ?? 'gray'}
radius="sm"
size="sm"
>
{STATUS_LABELS[row.original.status] ?? row.original.status}
</Badge> </Badge>
), ),
}, },
{ {
header: t('applications.table.progress'), header: t('applications.table.progress'),
size: 180, size: 140,
cell: ({ row }) => ( cell: ({ row }) => (
<Progress <Stack gap={4}>
value={STATUS_PROGRESS[row.original.status]} <Progress
color={STATUS_COLORS[row.original.status]} value={STATUS_PROGRESS[row.original.status] ?? 30}
size="sm" color={STATUS_COLORS[row.original.status] ?? 'blue'}
radius="xl" size="xs"
/> radius="xl"
/>
<Text size="10px" c="dimmed" ta="right">
{STATUS_PROGRESS[row.original.status] ?? 30}%
</Text>
</Stack>
), ),
}, },
]; ];

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -11,22 +11,28 @@ import {
Card, Card,
Center, Center,
Container, Container,
Grid,
Group, Group,
Paper, Paper,
SegmentedControl,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
ThemeIcon, ThemeIcon,
Title, Title,
useComputedColorScheme,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCertificate, IconArrowRight,
IconClipboardList, IconClipboardList,
IconClockHour4, IconClockHour4,
IconCreditCard, IconCreditCard,
IconFileText, IconFileText,
IconShieldCheck, IconHelpCircle,
IconPlus,
IconPrinter,
IconShip,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge'; import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge';
import { import {
@@ -35,29 +41,33 @@ import {
TERMINAL_STATUSES, TERMINAL_STATUSES,
useGetCertificateUrlMutation, useGetCertificateUrlMutation,
useGetMyApplicationsQuery, useGetMyApplicationsQuery,
useGetMyDashboardSummaryQuery,
useGetMyLicensesQuery, useGetMyLicensesQuery,
useGetMyPersonalDocumentsQuery,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api'; import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue'; import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard'; import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
import { dashboardApplicationColumns } from './columns'; import { dashboardApplicationColumns } from './columns';
import { DashboardStats } from './DashboardStats';
import {
DashboardCharts,
StatusDonutCard,
type MonthlyTrendItem,
type StatusDistributionItem,
} from './DashboardCharts';
import { QuickActionsCard } from './QuickActionsCard';
import { OnboardingChecklistCard } from './OnboardingChecklistCard';
import { ExportStatementModal } from './ExportStatementModal';
/** /**
* The applicant's home screen. * Days before expiry at which a licence is worth flagging when its type
* * carries no renewal window of its own. The window itself is configured per
* Ordered by what the applicant needs from it: first anything blocked on them, * licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
* then the records they already have — licences, then applications — and only * what the API's `renewable` flag is computed from, so it is read off the
* then the catalogue to file something new. A returning applicant comes here to * licence rather than assumed here.
* check on their own things, not to shop; the catalogue used to sit above both
* and pushed them below the fold.
*
* The exception is an applicant with nothing at all, for whom both sections are
* empty and the catalogue *is* the page. Every figure is the signed-in user's
* own data — there are no illustrative numbers on this page.
*/ */
/** Days before expiry at which a licence is worth flagging. */
const EXPIRY_WARNING_DAYS = 60; const EXPIRY_WARNING_DAYS = 60;
function daysUntil(date: string): number { function daysUntil(date: string): number {
@@ -79,6 +89,9 @@ function formatMoney(
export function DashboardPage() { export function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const [appFilter, setAppFilter] = useState<'ALL' | 'IN_REVIEW' | 'APPROVED' | 'ACTION'>('ALL');
const [exportOpened, setExportOpened] = useState(false);
const displayName = useSelector( const displayName = useSelector(
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) => (state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
state.auth.user?.name?.en || state.auth.user?.username || '', state.auth.user?.name?.en || state.auth.user?.username || '',
@@ -86,8 +99,9 @@ export function DashboardPage() {
const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery(); const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery(); const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] = const { data: personalDocs } = useGetMyPersonalDocumentsQuery();
useGetCertificateUrlMutation(); const { data: summaryData } = useGetMyDashboardSummaryQuery();
const [getCertificateUrl, { isLoading: isDownloading }] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense(); const { renewLicense, isRenewing } = useRenewLicense();
const items = useMemo(() => applications?.items ?? [], [applications]); const items = useMemo(() => applications?.items ?? [], [applications]);
@@ -100,19 +114,118 @@ export function DashboardPage() {
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT', (a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
); );
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE'); const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
// Nothing filed and nothing held: the two "my …" sections would both be
// empty, so they collapse into one panel and the catalogue carries the page.
const hasNoRecords = items.length === 0 && heldLicenses.length === 0;
const expiringSoon = activeLicenses.filter((l) => { const expiringSoon = activeLicenses.filter((l) => {
const days = daysUntil(l.expiryDate); const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS; const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
return days >= 0 && days <= window;
}); });
const documentsCount = useMemo(() => {
if (!personalDocs?.slots) return 0;
return personalDocs.slots.reduce(
(sum, slot) => sum + (slot.files?.length ? slot.files.length : 0),
0,
);
}, [personalDocs]);
const approvedApplications = useMemo(() => {
const appApproved = items.filter((a) =>
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
).length;
return Math.max(appApproved, activeLicenses.length);
}, [items, activeLicenses]);
// Status distribution for donut chart
const statusDistribution = useMemo<StatusDistributionItem[]>(() => {
if (summaryData?.statusDistribution?.length) {
return summaryData.statusDistribution;
}
const approved = approvedApplications;
const inReview = inProgress.length;
const draftAndAction = needsMe.length + items.filter((a) => a.status === 'DRAFT').length;
return [
{ name: t('dashboard.charts.approved'), value: approved, color: '#12b886' },
{ name: t('dashboard.charts.underReview'), value: inReview, color: '#2a59ac' },
{ name: t('dashboard.charts.draftPending'), value: draftAndAction, color: '#fab005' },
];
}, [summaryData, approvedApplications, inProgress.length, needsMe.length, items, t]);
// Monthly timeline trend
const monthlyTrend = useMemo<MonthlyTrendItem[]>(() => {
if (summaryData?.monthlyTrend?.length) {
return summaryData.monthlyTrend;
}
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const now = new Date();
const trend: MonthlyTrendItem[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const mIdx = d.getMonth();
const yr = d.getFullYear();
const matches = items.filter((app) => {
if (!app.createdAt) return false;
const appD = new Date(app.createdAt);
return appD.getFullYear() === yr && appD.getMonth() === mIdx;
});
const submitted = matches.filter((a) => a.status !== 'DRAFT').length;
const approved = matches.filter((a) =>
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
).length;
trend.push({
month: monthNames[mIdx],
year: yr,
submitted,
approved,
});
}
return trend;
}, [summaryData, items]);
const categoryBreakdown = useMemo(() => {
if (summaryData?.categoryBreakdown?.length) {
return summaryData.categoryBreakdown;
}
const catMap: Record<string, number> = {};
items.forEach((app) => {
const cat = app.licenseType?.category || 'GENERAL';
const label = cat.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
catMap[label] = (catMap[label] || 0) + 1;
});
return Object.entries(catMap).map(([category, count]) => ({ category, count }));
}, [summaryData, items]);
// Filtered applications for recent table
const filteredApplications = useMemo(() => {
switch (appFilter) {
case 'IN_REVIEW':
return inProgress;
case 'APPROVED':
return items.filter((a) =>
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
);
case 'ACTION':
return needsMe;
case 'ALL':
default:
return items;
}
}, [items, inProgress, needsMe, appFilter]);
async function downloadCertificate(license: IssuedLicense) { async function downloadCertificate(license: IssuedLicense) {
const result = await getCertificateUrl(license.id).unwrap(); const result = await getCertificateUrl(license.id).unwrap();
window.open(result.url, '_blank', 'noopener'); window.open(result.url, '_blank', 'noopener');
} }
function scrollToCatalogue() {
const el = document.getElementById('license-catalogue-section');
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
}
if (isLoading) { if (isLoading) {
return <PageLoader label={t('dashboard.loading')} height={450} />; return <PageLoader label={t('dashboard.loading')} height={450} />;
} }
@@ -120,19 +233,24 @@ export function DashboardPage() {
return ( return (
<Container size="xl" py="lg"> <Container size="xl" py="lg">
<Stack gap="xl"> <Stack gap="xl">
{/* Coastal Modern Hero Section */}
<Hero <Hero
displayName={displayName} displayName={displayName}
applicationCount={items.length} applicationCount={items.length}
licenseCount={activeLicenses.length} licenseCount={activeLicenses.length}
onApplyClick={scrollToCatalogue}
onExportClick={() => setExportOpened(true)}
/> />
{/* A prompt, not a gate — dismissible and it never blocks the page. */} {/* Profile Completion Nudge */}
<ProfileCompletionNudge /> <ProfileCompletionNudge />
{/* Priority Action Required */}
{needsMe.length > 0 && ( {needsMe.length > 0 && (
<ActionRequired applications={needsMe} navigate={navigate} /> <ActionRequired applications={needsMe} navigate={navigate} />
)} )}
{/* Expiring Soon Notice */}
{expiringSoon.length > 0 && ( {expiringSoon.length > 0 && (
<Alert <Alert
variant="light" variant="light"
@@ -154,85 +272,206 @@ export function DashboardPage() {
</Alert> </Alert>
)} )}
<StatRow {/* KPI Stat Cards */}
inProgress={inProgress.length} <DashboardStats
needsMe={needsMe.length}
activeLicenses={activeLicenses.length} activeLicenses={activeLicenses.length}
expiringSoon={expiringSoon.length} pendingApplications={inProgress.length}
approvedApplications={approvedApplications}
documentsCount={documentsCount}
/> />
{hasNoRecords ? ( {/* If user is brand new with 0 applications & 0 licenses, show OnboardingChecklistCard */}
<GetStartedPanel /> {items.length === 0 && activeLicenses.length === 0 ? (
<OnboardingChecklistCard
displayName={displayName}
documentsCount={documentsCount}
onBrowseCatalogue={scrollToCatalogue}
/>
) : ( ) : (
<> <Grid gutter="md">
<Section title={t('dashboard.sections.myLicences.title')}> <Grid.Col span={{ base: 12, md: 8 }}>
{heldLicenses.length === 0 ? ( <DashboardCharts
<EmptyCard message={t('applications.licences.empty')} /> statusDistribution={statusDistribution}
) : ( monthlyTrend={monthlyTrend}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> categoryBreakdown={categoryBreakdown}
{heldLicenses.map((license) => ( totalApplications={items.length}
<LicenseCard />
key={license.id} </Grid.Col>
license={license} <Grid.Col span={{ base: 12, md: 4 }}>
isDownloading={isDownloading} <StatusDonutCard
isRenewing={isRenewing} statusDistribution={statusDistribution}
onDownload={() => downloadCertificate(license)} totalApplications={items.length}
onRenew={() => renewLicense(license)} />
/> </Grid.Col>
))} </Grid>
</SimpleGrid>
)}
</Section>
<Section
title={t('dashboard.sections.myApplications.title')}
action={
items.length > 0 ? (
<Anchor
size="sm"
onClick={() => navigate('/licensing/applications')}
>
{t('common.viewAll')}
</Anchor>
) : undefined
}
>
{items.length === 0 ? (
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
) : (
<ApplicationTable
applications={items.slice(0, 6)}
navigate={navigate}
onRefresh={refetch}
/>
)}
</Section>
</>
)} )}
<Section {/* Two-Column Section: Left (Applications & Licenses), Right (Actions & Support) */}
title={t('dashboard.sections.apply.title')} <Grid gutter="xl">
description={t('dashboard.sections.apply.description')} <Grid.Col span={{ base: 12, lg: 8 }}>
> <Stack gap="xl">
<LicenseCatalogue /> {/* Recent Applications Card */}
</Section> <Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="md" wrap="wrap" gap="sm">
<Box>
<Title order={4}>{t('dashboard.sections.myApplications.title')}</Title>
<Text size="xs" c="dimmed">
{t('applications.subtitle')}
</Text>
</Box>
<Group gap="sm">
{items.length > 0 && (
<SegmentedControl
size="xs"
radius="md"
value={appFilter}
onChange={(val) => setAppFilter(val as 'ALL' | 'IN_REVIEW' | 'APPROVED' | 'ACTION')}
data={[
{ label: t('dashboard.table.all'), value: 'ALL' },
{ label: t('dashboard.table.inReview'), value: 'IN_REVIEW' },
{ label: t('dashboard.table.approved'), value: 'APPROVED' },
{ label: t('dashboard.table.actionRequired'), value: 'ACTION' },
]}
/>
)}
{items.length > 0 && (
<Anchor
size="sm"
fw={600}
onClick={() => navigate('/licensing/applications')}
>
<Group gap={4} wrap="nowrap">
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>
)}
</Group>
</Group>
{filteredApplications.length === 0 ? (
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
) : (
<ApplicationTable
applications={filteredApplications.slice(0, 6)}
navigate={navigate}
onRefresh={refetch}
/>
)}
</Card>
{/* My Licences Section */}
<Section
title={t('dashboard.sections.myLicences.title')}
action={
heldLicenses.length > 0 ? (
<Anchor
size="sm"
fw={600}
onClick={() => navigate('/licensing/applications')}
>
<Group gap={4} wrap="nowrap">
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>
) : undefined
}
>
{heldLicenses.length === 0 ? (
<EmptyCard message={t('applications.licences.empty')} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{heldLicenses.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloading}
isRenewing={isRenewing}
onDownload={() => downloadCertificate(license)}
onRenew={() => renewLicense(license)}
/>
))}
</SimpleGrid>
)}
</Section>
</Stack>
</Grid.Col>
{/* Right Column: Quick Actions & Help */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<QuickActionsCard onApplyClick={scrollToCatalogue} />
<Card withBorder radius="md" padding="lg">
<Group gap="xs" mb="xs">
<ThemeIcon variant="light" color="emaTeal" size="md" radius="md">
<IconHelpCircle size={18} />
</ThemeIcon>
<Text fw={700} size="sm">
{t('dashboard.supportModal.title')}
</Text>
</Group>
<Text size="xs" c="dimmed" lh={1.5} mb="sm">
{t('dashboard.supportModal.description')}
</Text>
<Button
variant="light"
color="emaPrimary"
size="xs"
fullWidth
onClick={() => navigate('/licensing/applications')}
>
{t('common.learnMore')}
</Button>
</Card>
</Stack>
</Grid.Col>
</Grid>
{/* License Catalogue Section */}
<Box id="license-catalogue-section" pt="md">
<Section
title={t('dashboard.sections.apply.title')}
description={t('dashboard.sections.apply.description')}
>
<LicenseCatalogue />
</Section>
</Box>
{/* Statement Export Modal */}
<ExportStatementModal
opened={exportOpened}
onClose={() => setExportOpened(false)}
displayName={displayName}
applications={items}
licenses={heldLicenses}
documentsCount={documentsCount}
/>
</Stack> </Stack>
</Container> </Container>
); );
} }
// --------------------------------------------------------------- components // --------------------------------------------------------------- sub-components
function Hero({ function Hero({
displayName, displayName,
applicationCount, applicationCount,
licenseCount, licenseCount,
onApplyClick,
onExportClick,
}: { }: {
displayName: string; displayName: string;
applicationCount: number; applicationCount: number;
licenseCount: number; licenseCount: number;
onApplyClick: () => void;
onExportClick?: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const summary = const summary =
applicationCount === 0 && licenseCount === 0 applicationCount === 0 && licenseCount === 0
? t('dashboard.hero.summaryEmpty') ? t('dashboard.hero.summaryEmpty')
@@ -244,34 +483,80 @@ function Hero({
return ( return (
<Paper <Paper
radius="lg" radius="lg"
p="xl" p={{ base: 'lg', md: 'xl' }}
style={{ style={{
background: background: isDark
'linear-gradient(135deg, var(--mantine-color-emaPrimary-7) 0%, var(--mantine-color-emaPrimary-9) 55%, var(--mantine-color-emaTeal-8) 100%)', ? 'linear-gradient(135deg, #0e2042 0%, #153261 45%, #0d463b 100%)'
: 'linear-gradient(135deg, #18438b 0%, #2054a8 45%, #138f77 100%)',
color: 'white', color: 'white',
position: 'relative',
overflow: 'hidden',
boxShadow: isDark
? '0 8px 24px rgba(0, 0, 0, 0.45)'
: '0 8px 24px rgba(32, 84, 168, 0.25)',
}} }}
> >
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Box> <Box style={{ zIndex: 2, maxWidth: 620 }}>
<Text size="sm" style={{ opacity: 0.85 }}> <Text size="xs" fw={600} tt="uppercase" style={{ opacity: 0.85, letterSpacing: '0.5px' }}>
{t('app.authority')} {t('app.authority', 'Ethiopian Maritime Authority')}
</Text> </Text>
<Title order={2} mt={4} c="white"> <Title order={2} mt={4} c="white" fw={700} style={{ letterSpacing: '-0.3px' }}>
{displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')} {displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
</Title> </Title>
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}> <Text size="sm" mt="xs" style={{ opacity: 0.9, lineHeight: 1.55 }}>
{summary} {summary}
</Text> </Text>
<Group gap="sm" mt="md" wrap="wrap">
<Button
size="sm"
color="white"
variant="white"
c="emaPrimary.8"
leftSection={<IconPlus size={16} stroke={2.5} />}
onClick={onApplyClick}
style={{ fontWeight: 600 }}
>
{t('dashboard.quickActionsList.applyLicense')}
</Button>
{onExportClick && (
<Button
size="sm"
variant="outline"
color="white"
leftSection={<IconPrinter size={16} />}
onClick={onExportClick}
style={{
borderColor: 'rgba(255, 255, 255, 0.45)',
color: 'white',
fontWeight: 600,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
}}
>
{t('dashboard.export.button')}
</Button>
)}
</Group>
</Box> </Box>
<ThemeIcon
size={56} <Box
radius="md"
variant="transparent"
c="white"
visibleFrom="sm" visibleFrom="sm"
style={{
width: 120,
height: 120,
borderRadius: 999,
backgroundColor: isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
flexShrink: 0,
}}
> >
<IconShieldCheck size={44} stroke={1.3} /> <IconShip size={60} stroke={1.3} color="white" />
</ThemeIcon> </Box>
</Group> </Group>
</Paper> </Paper>
); );
@@ -285,12 +570,19 @@ function ActionRequired({
navigate: (path: string) => void; navigate: (path: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
return ( return (
<Card <Card
withBorder withBorder
radius="md" radius="md"
padding="md" padding="md"
style={{ backgroundColor: 'var(--mantine-color-orange-light)' }} style={{
backgroundColor: isDark
? 'rgba(247, 103, 7, 0.12)'
: 'var(--mantine-color-orange-light)',
}}
> >
<Group gap="xs" mb="sm"> <Group gap="xs" mb="sm">
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled"> <ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
@@ -304,7 +596,13 @@ function ActionRequired({
{applications.map((app) => { {applications.map((app) => {
const detail = detailFor(app, t); const detail = detailFor(app, t);
return ( return (
<Paper key={app.id} radius="sm" p="sm" withBorder> <Paper
key={app.id}
radius="sm"
p="sm"
withBorder
style={{ backgroundColor: 'var(--mantine-color-body)' }}
>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<ThemeIcon <ThemeIcon
@@ -343,7 +641,6 @@ function ActionRequired({
); );
} }
/** What the applicant has to do next, and where that happens. */
function detailFor( function detailFor(
app: LicenseApplication, app: LicenseApplication,
t: TFunction, t: TFunction,
@@ -385,48 +682,6 @@ function detailFor(
} }
} }
function StatRow({
inProgress,
needsMe,
activeLicenses,
expiringSoon,
}: {
inProgress: number;
needsMe: number;
activeLicenses: number;
expiringSoon: number;
}) {
const { t } = useTranslation();
const stats = [
{ label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' },
{ label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' },
{ label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' },
{ label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' },
];
return (
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
{stats.map((stat) => (
<Card key={stat.label} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={30} fw={700} lh={1.2} mt={4}>
{stat.value}
</Text>
</Box>
<ThemeIcon variant="light" color={stat.color} radius="md" size="lg">
<stat.icon size={18} />
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
);
}
function Section({ function Section({
title, title,
description, description,
@@ -456,8 +711,6 @@ function Section({
); );
} }
function ApplicationTable({ function ApplicationTable({
applications, applications,
navigate, navigate,
@@ -492,30 +745,6 @@ function ApplicationTable({
); );
} }
/**
* The first-visit panel, in place of two empty sections saying the same thing.
* It carries no call to action of its own — the catalogue is directly beneath
* it, and a button that only scrolls the page is noise.
*/
function GetStartedPanel() {
const { t } = useTranslation();
return (
<Card withBorder radius="md" padding="xl">
<Group gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="emaPrimary" size={44} radius="md">
<IconCertificate size={24} stroke={1.5} />
</ThemeIcon>
<Box>
<Title order={4}>{t('dashboard.getStarted.title')}</Title>
<Text size="sm" c="dimmed" mt={4} maw={620}>
{t('dashboard.getStarted.body')}
</Text>
</Box>
</Group>
</Card>
);
}
function EmptyCard({ message }: { message: string }) { function EmptyCard({ message }: { message: string }) {
return ( return (
<Card withBorder radius="md" padding="xl"> <Card withBorder radius="md" padding="xl">

View File

@@ -490,9 +490,16 @@ export function LicenseApplicationPage() {
// underneath them. // underneath them.
const editableWhileSubmitted = const editableWhileSubmitted =
application.status === "SUBMITTED" && !application.assignedOfficerId; application.status === "SUBMITTED" && !application.assignedOfficerId;
// A draft of a type EMA has since stopped offering is frozen: the server
// refuses every edit and the submit (`assertTypeOpen`), so the form is shown
// read-only rather than letting the applicant fill it in and fail at the
// end. Anything already submitted keeps moving and is not affected.
const typeClosed =
application.status === "DRAFT" && config.licenseType.isActive === false;
const readOnly = const readOnly =
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) && typeClosed ||
!editableWhileSubmitted; (!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
!editableWhileSubmitted);
// A DRAFT has nothing worth summarising yet, so it always opens straight // A DRAFT has nothing worth summarising yet, so it always opens straight
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens // into the wizard; every later status (including RESUBMIT_REQUIRED) opens
// to the summary first. // to the summary first.
@@ -828,6 +835,24 @@ export function LicenseApplicationPage() {
</Alert> </Alert>
)} )}
{typeClosed && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t(
"licenseApplication.typeClosedTitle",
"This service is no longer offered",
)}
mb="md"
>
{t(
"licenseApplication.typeClosed",
"EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.",
{ name: localized(config.licenseType.name) },
)}
</Alert>
)}
{config.licenseType.requiresIssuanceScheduling && {config.licenseType.requiresIssuanceScheduling &&
(application.status === "PAYMENT_CONFIRMED" || (application.status === "PAYMENT_CONFIRMED" ||
application.status === "SCHEDULED") && ( application.status === "SCHEDULED") && (

View File

@@ -98,6 +98,9 @@ export function applicationActionsColumn(
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && ( deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
<Button <Button
size="xs" size="xs"
// The server freezes a draft of a type that has been closed, so
// there is nothing to continue into; discarding stays available.
disabled={app.status === 'DRAFT' && app.licenseType?.isActive === false}
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'} loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
variant={ variant={
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle' app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
@@ -116,7 +119,9 @@ export function applicationActionsColumn(
} }
> >
{app.status === 'DRAFT' {app.status === 'DRAFT'
? t('applications.actions.continue') ? app.licenseType?.isActive === false
? t('applications.actions.closed')
: t('applications.actions.continue')
: app.status === 'RESUBMIT_REQUIRED' : app.status === 'RESUBMIT_REQUIRED'
? t('applications.actions.fixResubmit') ? t('applications.actions.fixResubmit')
: app.status === 'PAYMENT_PENDING' : app.status === 'PAYMENT_PENDING'

View File

@@ -64,7 +64,13 @@ import { applicationColumns } from './columns';
import { applicationActionsColumn } from './actions'; import { applicationActionsColumn } from './actions';
import classes from '../MyApplicationsPage.module.css'; import classes from '../MyApplicationsPage.module.css';
/** Days before expiry at which a licence is worth flagging. */ /**
* Days before expiry at which a licence is worth flagging when its type
* carries no renewal window of its own. The window itself is configured per
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
* what the API's `renewable` flag is computed from, so it is read off the
* licence rather than assumed here.
*/
const EXPIRY_WARNING_DAYS = 60; const EXPIRY_WARNING_DAYS = 60;
function daysUntil(date: string): number { function daysUntil(date: string): number {
@@ -240,7 +246,8 @@ export function MyApplicationsPage() {
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE'); const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
const expiringSoon = activeLicences.filter((l) => { const expiringSoon = activeLicences.filter((l) => {
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate); const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS; const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
return days >= 0 && days <= window;
}); });
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');

View File

@@ -49,7 +49,9 @@ import { useApplicationPayment } from "../../payments/hooks/useApplicationPaymen
* The stages a document passes through, for the progress stepper. Derived * The stages a document passes through, for the progress stepper. Derived
* from the status the API moves, never stored separately. * from the status the API moves, never stored separately.
*/ */
const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [ type Stage = { label: string; statuses: SeafarerDocumentStatus[] };
const STAGES: Stage[] = [
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] }, { label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
{ label: "Payment", statuses: ["PAYMENT_PENDING"] }, { label: "Payment", statuses: ["PAYMENT_PENDING"] },
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] }, { label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
@@ -57,9 +59,26 @@ const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
{ label: "Issued", statuses: ["ISSUED"] }, { label: "Issued", statuses: ["ISSUED"] },
]; ];
function stageIndexFor(status: SeafarerDocumentStatus): number { /**
* Whether this document is handed over at the counter. Configured per type
* on the backoffice Behaviour tab (`requiresIssuanceScheduling`); an older
* server that does not report it is treated as the collected-in-person
* default the documents always had.
*/
function collectedInPerson(document: SeafarerDocument): boolean {
return document.requiresIssuanceScheduling ?? true;
}
/** The stepper for one document: no pickup step for a type issued on payment. */
function stagesFor(document: SeafarerDocument): Stage[] {
return collectedInPerson(document)
? STAGES
: STAGES.filter((stage) => stage.label !== "Pickup Scheduled");
}
function stageIndexFor(stages: Stage[], status: SeafarerDocumentStatus): number {
let reached = -1; let reached = -1;
STAGES.forEach((stage, i) => { stages.forEach((stage, i) => {
if (stage.statuses.includes(status)) reached = i; if (stage.statuses.includes(status)) reached = i;
}); });
return reached; return reached;
@@ -82,7 +101,12 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation(); const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation(); const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind]; const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const activeStep = stageIndexFor(document.status); const stages = stagesFor(document);
const activeStep = stageIndexFor(stages, document.status);
// Offered inside the type's configured renewal window, and only where the
// type allows it — decided by the API, not re-derived from a date here.
const renewable = document.renewable ?? true;
const reissuable = document.reissuable ?? true;
async function renewOrReplace(action: () => ReturnType<typeof renew>) { async function renewOrReplace(action: () => ReturnType<typeof renew>) {
try { try {
@@ -133,7 +157,7 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
{document.status !== "REJECTED" && document.status !== "CANCELLED" && ( {document.status !== "REJECTED" && document.status !== "CANCELLED" && (
<Stepper active={activeStep} size="sm" color="teal"> <Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => ( {stages.map((stage, i) => (
<Stepper.Step <Stepper.Step
key={stage.label} key={stage.label}
label={stage.label} label={stage.label}
@@ -171,7 +195,9 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
)} )}
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && ( {(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md"> <Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
Payment received. The Authority will schedule a date for you to collect your {title}. {collectedInPerson(document)
? `Payment received. The Authority will schedule a date for you to collect your ${title}.`
: `Payment received. Your ${title} is being issued.`}
</Alert> </Alert>
)} )}
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && ( {document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
@@ -193,24 +219,28 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}> <Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF Download PDF
</Button> </Button>
<Button {renewable && (
size="xs" <Button
variant="default" size="xs"
leftSection={<IconRefresh size={14} />} variant="default"
loading={renewing} leftSection={<IconRefresh size={14} />}
onClick={() => renewOrReplace(() => renew(document.id))} loading={renewing}
> onClick={() => renewOrReplace(() => renew(document.id))}
Renew >
</Button> Renew
<Button </Button>
size="xs" )}
variant="default" {reissuable && (
leftSection={<IconReplace size={14} />} <Button
loading={replacing} size="xs"
onClick={() => renewOrReplace(() => replaceDoc(document.id))} variant="default"
> leftSection={<IconReplace size={14} />}
Report Lost/Damaged loading={replacing}
</Button> onClick={() => renewOrReplace(() => replaceDoc(document.id))}
>
Report Lost/Damaged
</Button>
)}
</Group> </Group>
</Group> </Group>
</Alert> </Alert>

View File

@@ -151,6 +151,79 @@ export const am: Translations = {
}, },
stats: { stats: {
expiringSoon: 'በቅርቡ የሚያበቃ', expiringSoon: 'በቅርቡ የሚያበቃ',
activeLicenses: 'ንቁ ፍቃዶች',
pendingApplications: 'በመጠባበቅ ላይ ያሉ ማመልከቻዎች',
approved: 'የጸደቁ',
documents: 'ሰነዶች',
upToDate: 'የተሟሉ',
allInGoodStanding: 'ንቁ እና ህጋዊ',
inReview: 'በግምገማ ላይ',
passRate: 'ዝግጁ / የተሰጠ',
},
charts: {
statusTitle: 'የማመልከቻ ሁኔታ',
activityTitle: 'የማመልከቻ እንቅስቃሴ',
activitySubtitle: 'የወርሃዊ ማመልከቻዎች እና ማጽደቆች',
viewTimeline: 'የጊዜ ሰሌዳ',
viewCategory: 'በአገልግሎት አይነት',
categoryTitle: 'ማመልከቻዎች በአገልግሎት ዘርፍ',
categoryCount: 'ማመልከቻዎች',
approved: 'የጸደቀ',
underReview: 'በግምገማ ላይ',
draftPending: 'ረቂቅ / በመጠባበቅ ላይ',
total: 'ጠቅላላ',
submitted: 'የገቡ',
noData: 'እስካሁን ምንም እንቅስቃሴ አልተመዘገበም',
},
onboarding: {
title: 'የኢ.ባ.ባ ፖርታል ጅምር መመሪያ',
subtitle: 'የተረጋገጠ ፍቃድ ለማግኘት እነዚህን 3 ቀላል ደረጃዎች ያጠናቅቁ።',
progress: 'ከ{{total}} ውስጥ {{completed}} ተጠናቋል',
step1: 'የመገለጫ ዝርዝሮችን ያረጋግጡ',
step1Desc: 'የእውቂያ መረጃ እና ምዝገባ ዝርዝሮች',
step2: 'ሰነዶችን ወደ ቮልት ይስቀሉ',
step2Desc: 'ፓስፖርት፣ መታወቂያ ወይም የምስክር ወረቀቶችን ይስቀሉ',
step3: 'የመጀመሪያውን ፍቃድ ያመልክቱ',
step3Desc: 'ካታሎጉን ያስሱ እና ማመልከቻዎን ያስገቡ',
viewVault: 'ቮልት ክፈት',
browseCatalogue: 'ካታሎግ አስስ',
completed: 'ተጠናቋል',
},
export: {
button: 'የፍቃዶች መግለጫ አውርድ',
title: 'ኦፊሴላዊ የፍቃድ መግለጫ',
subtitle: 'የተሰጡ ፍቃዶች እና በማመልከት ላይ ያሉ ማመልከቻዎች መዝገብ',
accountHolder: 'የመለያው ባለቤት',
issueDate: 'የወጣበት ቀን',
activeLicencesCount: 'ንቁ ፍቃዶች',
pendingApplicationsCount: 'በግምገማ ላይ',
verifiedDocumentsCount: 'የተረጋገጡ ሰነዶች',
printButton: 'አትም / ፒዲኤፍ አስቀምጥ',
close: 'ዝጋ',
officialFooter: 'በኢትዮጵያ የባህር ባለስልጣን በኤሌክትሮኒክ የተሰጠ። ለማረጋገጥ licensing@ema.gov.et ያግኙ።',
},
quickActionsList: {
applyLicense: 'ለአዲስ ፍቃድ ያመልክቱ',
applyLicenseDesc: 'ካታሎግ ያስሱ እና ማመልከቻ ይጀምሩ',
uploadDocument: 'ሰነድ ይስቀሉ',
uploadDocumentDesc: 'የግል መገለጫ ቮልት ሰነዶችን ያስተዳድሩ',
notifications: 'ማሳወቂያዎች',
notificationsDesc: 'የቅርብ ጊዜ ዝመናዎችን ያረጋግጡ',
support: 'ድጋፍ ያግኙ',
supportDesc: 'ጥያቄዎች እና ቴክኒካዊ ድጋፍ',
},
supportModal: {
title: 'የኢ.ባ.ባ ድጋፍ እና እገዛ',
description: 'በማመልከቻዎ ወይም በፍቃድዎ ላይ እርዳታ ይፈልጋሉ? የፍቃድ ድጋፍ ቡድናችን በስራ ሰዓታት ውስጥ ይገኛል።',
emailLabel: 'የኢሜይል ድጋፍ',
email: 'licensing@ema.gov.et',
phoneLabel: 'የስልክ መስመር',
phone: '+251 11 551 8855',
hoursLabel: 'የስራ ሰዓት',
hours: 'ሰኞ አርብ፣ 2:30 ጠዋት 11:30 ከሰዓት',
officeLabel: 'ዋና መስሪያ ቤት',
office: 'የኢትዮጵያ የባህር ባለስልጣን፣ አዲስ አበባ፣ ኢትዮጵያ',
close: 'ዝጋ',
}, },
sections: { sections: {
myLicences: { myLicences: {
@@ -171,6 +244,13 @@ export const am: Translations = {
}, },
table: { table: {
application: 'ማመልከቻ', application: 'ማመልከቻ',
submitted: 'የገባበት ቀን',
type: 'አይነት',
status: 'ሁኔታ',
all: 'ሁሉም',
inReview: 'በግምገማ ላይ',
approved: 'የጸደቀ',
actionRequired: 'እርምጃ የሚያስፈልገው',
}, },
}, },
@@ -223,6 +303,7 @@ export const am: Translations = {
}, },
actions: { actions: {
continue: 'ቀጥል', continue: 'ቀጥል',
closed: 'አገልግሎቱ ቆሟል',
fixResubmit: 'አስተካክለህ እንደገና አስገባ', fixResubmit: 'አስተካክለህ እንደገና አስገባ',
pay: '{{amount}} {{currency}} ክፈል', pay: '{{amount}} {{currency}} ክፈል',
certificate: 'የምስክር ወረቀት', certificate: 'የምስክር ወረቀት',
@@ -953,6 +1034,9 @@ export const am: Translations = {
}, },
licenseApplication: { licenseApplication: {
typeClosedTitle: 'ይህ አገልግሎት ከአሁን በኋላ አይሰጥም',
typeClosed:
'የ{{name}} አገልግሎት መሰጠቱ ቆሟል። ይህ ረቂቅ ከአሁን በኋላ ሊጠናቀቅ ወይም ሊገባ አይችልም፤ ከማመልከቻዎች ዝርዝርዎ ማጥፋት ይችላሉ።',
loading: 'ማመልከቻ በመጫን ላይ…', loading: 'ማመልከቻ በመጫን ላይ…',
fee: 'ክፍያ፡ {{amount}} {{currency}}', fee: 'ክፍያ፡ {{amount}} {{currency}}',
review: 'ግምገማ', review: 'ግምገማ',

View File

@@ -151,6 +151,79 @@ export const en = {
}, },
stats: { stats: {
expiringSoon: 'Expiring soon', expiringSoon: 'Expiring soon',
activeLicenses: 'Active Licenses',
pendingApplications: 'Pending Applications',
approved: 'Approved',
documents: 'Documents',
upToDate: 'Up to date',
allInGoodStanding: 'Active & valid',
inReview: 'In review',
passRate: 'Ready / Issued',
},
charts: {
statusTitle: 'Application Status',
activityTitle: 'Application Activity',
activitySubtitle: 'Monthly filings & approvals',
viewTimeline: 'Timeline',
viewCategory: 'By Category',
categoryTitle: 'Applications by Service',
categoryCount: 'Applications',
approved: 'Approved',
underReview: 'Under review',
draftPending: 'Draft / pending',
total: 'Total',
submitted: 'Submitted',
noData: 'No activity recorded yet',
},
onboarding: {
title: 'Getting Started with EMA Portal',
subtitle: 'Complete these 3 simple steps to get certified and verified.',
progress: '{{completed}} of {{total}} completed',
step1: 'Verify Profile Details',
step1Desc: 'Contact info and account registration',
step2: 'Upload Documents to Vault',
step2Desc: 'Upload passport, ID or certifications for reusable verification',
step3: 'Apply for First License',
step3Desc: 'Browse catalogue and submit your first application',
viewVault: 'Open Vault',
browseCatalogue: 'Browse Catalogue',
completed: 'Completed',
},
export: {
button: 'Export Statement',
title: 'Official Licensing Statement',
subtitle: 'Official record of issued licences and pending applications',
accountHolder: 'Account Holder',
issueDate: 'Statement Date',
activeLicencesCount: 'Active Licences',
pendingApplicationsCount: 'In Review',
verifiedDocumentsCount: 'Vault Documents',
printButton: 'Print / Save as PDF',
close: 'Close',
officialFooter: 'Issued electronically by the Ethiopian Maritime Authority. For validation, contact licensing@ema.gov.et.',
},
quickActionsList: {
applyLicense: 'Apply for new license',
applyLicenseDesc: 'Explore catalogue and start application',
uploadDocument: 'Upload a document',
uploadDocumentDesc: 'Manage verified profile vault documents',
notifications: 'Notifications',
notificationsDesc: 'Check recent authority updates',
support: 'Contact support',
supportDesc: 'Inquiries & technical assistance',
},
supportModal: {
title: 'EMA Support & Assistance',
description: 'Need help with your application or credentials? Our licensing support team is available during working hours.',
emailLabel: 'Email Support',
email: 'licensing@ema.gov.et',
phoneLabel: 'Helpline',
phone: '+251 11 551 8855',
hoursLabel: 'Working Hours',
hours: 'Mon Fri, 8:30 AM 5:30 PM (EAT)',
officeLabel: 'Headquarters',
office: 'Ethiopian Maritime Authority, Addis Ababa, Ethiopia',
close: 'Close',
}, },
sections: { sections: {
myLicences: { myLicences: {
@@ -171,6 +244,13 @@ export const en = {
}, },
table: { table: {
application: 'Application', application: 'Application',
submitted: 'Submitted',
type: 'Type',
status: 'Status',
all: 'All',
inReview: 'In Review',
approved: 'Approved',
actionRequired: 'Action Required',
}, },
}, },
@@ -223,6 +303,7 @@ export const en = {
}, },
actions: { actions: {
continue: 'Continue', continue: 'Continue',
closed: 'No longer offered',
fixResubmit: 'Fix & resubmit', fixResubmit: 'Fix & resubmit',
pay: 'Pay {{amount}} {{currency}}', pay: 'Pay {{amount}} {{currency}}',
certificate: 'Certificate', certificate: 'Certificate',
@@ -959,6 +1040,9 @@ export const en = {
}, },
licenseApplication: { licenseApplication: {
typeClosedTitle: 'This service is no longer offered',
typeClosed:
'EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.',
loading: 'Loading Application…', loading: 'Loading Application…',
fee: 'Fee: {{amount}} {{currency}}', fee: 'Fee: {{amount}} {{currency}}',
review: 'Review', review: 'Review',

View File

@@ -269,7 +269,7 @@ export const router = createBrowserRouter([
path: "/basic-training-certificate", path: "/basic-training-certificate",
element: ( element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}> <RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<SeamanBookPage /> <SeamanBookPage service="BTC" />
</RequirePermission> </RequirePermission>
), ),
}, },

View File

@@ -13,9 +13,10 @@ export default defineConfig({
port: 4200, port: 4200,
host: "localhost", host: "localhost",
proxy: { proxy: {
'/api': { "/api": {
target: 'https://ema-api-dev.triaplc.com', target: process.env.VITE_API_PROXY_TARGET || "https://ema-api-dev.triaplc.com",
changeOrigin: true, changeOrigin: true,
secure: false,
}, },
}, },
}, },

View File

@@ -10,7 +10,7 @@ import { resolveSessionContext } from "../session";
export const BASE_API_URL = export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[ (import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL" "VITE_BASE_API_URL"
]?.trim() || "http://localhost:3000/api"; ]?.trim() || "https://ema-api-dev.triaplc.com/api";
let _onTokenExpired: (() => Promise<string>) | null = null; let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null; let _onAuthFailure: (() => void) | null = null;

View File

@@ -74,6 +74,51 @@ const handlers: MockHandler[] = [
pattern: /^\/license-types$/, pattern: /^\/license-types$/,
respond: () => paginated(clone(mockLicenseTypes)), respond: () => paginated(clone(mockLicenseTypes)),
}, },
{
method: 'POST',
pattern: /^\/license-types$/,
respond: (req) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const key = String(body.key ?? '').trim().toUpperCase();
if (mockLicenseTypes.some((t) => t.key === key)) return undefined;
const created = {
id: `lt-${key.toLowerCase()}`,
category: 'MARITIME_PERSONNEL',
familyKind: 'LOGISTICS_LICENSE',
serviceKind: 'LICENSE',
workflowProfile: 'STANDARD',
feeNewApplication: null,
feeRenewal: null,
feeCurrency: 'ETB',
capitalThreshold: null,
validityMonths: 12,
slaHours: null,
inspectionRequired: true,
issuesCertificate: true,
renewalEnabled: true,
requiresIssuanceScheduling: false,
requiresOperatorMode: true,
formSchema: { sections: [] },
isActive: true,
sortOrder: mockLicenseTypes.length,
...body,
key,
};
(mockLicenseTypes as unknown[]).push(created);
return clone(created);
},
},
{
method: 'PATCH',
pattern: /^\/license-types\/([\w-]+)\/status$/,
respond: (req, match) => {
const found = mockLicenseTypes.find((t) => t.id === match[1]);
if (!found) return undefined;
const body = (req.body ?? {}) as { isActive?: boolean };
(found as { isActive: boolean }).isActive = body.isActive ?? found.isActive;
return clone(found);
},
},
{ {
method: 'GET', method: 'GET',
pattern: /^\/license-types\/requirements\/([\w-]+)$/, pattern: /^\/license-types\/requirements\/([\w-]+)$/,

View File

@@ -402,6 +402,7 @@ export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
remark: 'Ownership document is blurred — please re-upload a clearer scan.', remark: 'Ownership document is blurred — please re-upload a clearer scan.',
isResolved: false, isResolved: false,
resolvedAt: null, resolvedAt: null,
valueChangedAt: null,
createdAt: application.submittedAt ?? application.createdAt, createdAt: application.submittedAt ?? application.createdAt,
}, },
] ]
@@ -417,6 +418,7 @@ export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
remark: 'Ownership document is blurred — please re-upload a clearer scan.', remark: 'Ownership document is blurred — please re-upload a clearer scan.',
isResolved: false, isResolved: false,
resolvedAt: null, resolvedAt: null,
valueChangedAt: null,
createdAt: application.submittedAt ?? application.createdAt, createdAt: application.submittedAt ?? application.createdAt,
}, },
] ]

View File

@@ -1,5 +1,7 @@
import { baseApi } from '../../base-api'; import { baseApi } from '../../base-api';
import type { import type {
AdminDashboardAnalytics,
ApplicantDashboardSummary,
ExamStateView, ExamStateView,
AppNotification, AppNotification,
ApplicationDetail, ApplicationDetail,
@@ -9,6 +11,7 @@ import type {
Attachment, Attachment,
Department, Department,
DocumentRequirement, DocumentRequirement,
FamilyKind,
FormSchemaPalette, FormSchemaPalette,
FormSectionConfig, FormSectionConfig,
InitiatePaymentResult, InitiatePaymentResult,
@@ -19,6 +22,7 @@ import type {
LicenseCategoryDefinition, LicenseCategoryDefinition,
LicenseStatus, LicenseStatus,
LicenseType, LicenseType,
LicenseTypeCreate,
LicenseTypeRequirements, LicenseTypeRequirements,
OperatorType, OperatorType,
AssignableOfficer, AssignableOfficer,
@@ -173,6 +177,38 @@ export const licensingApi = baseApi
providesTags: () => [listTag('LicenseType')], providesTags: () => [listTag('LicenseType')],
}), }),
/**
* Creates the licence type row.
*
* The one write on this resource with no screen until now: everything
* after creation — form, documents, staff rules, fees, validity,
* certificate design — had an editor, but the type itself had to be
* added by seed or by calling the API directly.
*/
createLicenseType: builder.mutation<LicenseType, LicenseTypeCreate>({
query: (body) => ({ url: '/license-types', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseType')]),
}),
/**
* Replaces the row's own fields. The narrow routes (`/fees`,
* `/validity`, `/behavior`, `/form-schema`) are still the way to change
* what they own; this is the catalogue entry itself — key, names,
* category, family kind, prefix, sort order, active.
*/
updateLicenseType: builder.mutation<
LicenseType,
{ id: string } & Partial<LicenseTypeCreate>
>({
query: ({ id, ...body }) => ({
url: `/license-types/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/** /**
* Fee configuration, for licensing administrators. * Fee configuration, for licensing administrators.
* *
@@ -222,6 +258,7 @@ export const licensingApi = baseApi
id: string; id: string;
workflowProfile?: WorkflowProfile; workflowProfile?: WorkflowProfile;
serviceKind?: ServiceKind; serviceKind?: ServiceKind;
familyKind?: FamilyKind;
completionEffect?: CompletionEffect | null; completionEffect?: CompletionEffect | null;
certificateCategory?: CertificateCategory | null; certificateCategory?: CertificateCategory | null;
requiresExamination?: boolean; requiresExamination?: boolean;
@@ -252,6 +289,26 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)], error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}), }),
/**
* Opens or closes a type to new applications. Applications already in
* flight hold the type by id and keep running; only the portal
* catalogue changes. Closing also notifies every holder, applicant and
* drafter of the type, and freezes their drafts — server-side, so the
* same happens whichever client flips it.
*/
updateLicenseStatus: builder.mutation<
LicenseType,
{ id: string; isActive: boolean }
>({
query: ({ id, isActive }) => ({
url: `/license-types/${id}/status`,
method: 'PATCH',
body: { isActive },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
}),
/** Validity is edited beside the certificate design, not with the fees. */ /** Validity is edited beside the certificate design, not with the fees. */
updateLicenseValidity: builder.mutation< updateLicenseValidity: builder.mutation<
LicenseType, LicenseType,
@@ -492,6 +549,11 @@ export const licensingApi = baseApi
providesTags: () => [listTag('LicenseApplication')], providesTags: () => [listTag('LicenseApplication')],
}), }),
getMyDashboardSummary: builder.query<ApplicantDashboardSummary, void>({
query: () => ({ url: '/license-applications/mine/summary' }),
providesTags: () => [listTag('LicenseApplication'), listTag('License')],
}),
getApplication: builder.query<ApplicationDetail, string>({ getApplication: builder.query<ApplicationDetail, string>({
query: (id) => ({ url: `/license-applications/${id}` }), query: (id) => ({ url: `/license-applications/${id}` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
@@ -746,6 +808,17 @@ export const licensingApi = baseApi
providesTags: () => [listTag('ApplicationQueue')], providesTags: () => [listTag('ApplicationQueue')],
}), }),
getAdminDashboardAnalytics: builder.query<
AdminDashboardAnalytics,
{ period?: string } | void
>({
query: (args) => ({
url: '/license-application-review/dashboard-analytics',
params: args?.period ? { period: args.period } : undefined,
}),
providesTags: () => [listTag('ApplicationQueue'), listTag('License')],
}),
getApplicationForReview: builder.query<ApplicationDetail, string>({ getApplicationForReview: builder.query<ApplicationDetail, string>({
query: (id) => ({ url: `/license-application-review/${id}` }), query: (id) => ({ url: `/license-application-review/${id}` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
@@ -1364,6 +1437,8 @@ export const licensingApi = baseApi
export const { export const {
useGetLicenseTypesQuery, useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery, useGetLicenseCategoriesQuery,
useCreateLicenseTypeMutation,
useUpdateLicenseTypeMutation,
useUpdateLicenseFeesMutation, useUpdateLicenseFeesMutation,
useUpdateLicenseBehaviorMutation, useUpdateLicenseBehaviorMutation,
useUpdateFormSchemaMutation, useUpdateFormSchemaMutation,
@@ -1389,11 +1464,13 @@ export const {
useUpdateRankMutation, useUpdateRankMutation,
useDeleteRankMutation, useDeleteRankMutation,
useUpdateLicenseValidityMutation, useUpdateLicenseValidityMutation,
useUpdateLicenseStatusMutation,
useGetLicenseTypeRequirementsQuery, useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation, useCreateApplicationMutation,
useGetExamStateForApplicationQuery, useGetExamStateForApplicationQuery,
useDiscardApplicationMutation, useDiscardApplicationMutation,
useGetMyApplicationsQuery, useGetMyApplicationsQuery,
useGetMyDashboardSummaryQuery,
useGetApplicationQuery, useGetApplicationQuery,
useInitiatePaymentMutation, useInitiatePaymentMutation,
useBypassPaymentMutation, useBypassPaymentMutation,
@@ -1417,6 +1494,7 @@ export const {
useGetAssignedToMeQuery, useGetAssignedToMeQuery,
useGetAllApplicationsQuery, useGetAllApplicationsQuery,
useGetQueueCountsQuery, useGetQueueCountsQuery,
useGetAdminDashboardAnalyticsQuery,
useGetLicenseTemplatesQuery, useGetLicenseTemplatesQuery,
useGetTemplateVariablesQuery, useGetTemplateVariablesQuery,
useGetBuiltInTemplateQuery, useGetBuiltInTemplateQuery,

View File

@@ -411,6 +411,18 @@ const ERROR_MESSAGES: Record<string, string> = {
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.', 'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
form_schema_missing_protected_paths: form_schema_missing_protected_paths:
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.', 'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
license_type_key_taken:
'A licence type with this key already exists (it may be archived). Choose a different key.',
license_type_key_in_use:
'Applications already reference this licence type, so its key can no longer be changed.',
invalid_form_schema:
'The form schema is not consistent — a condition or field reference points at something the form does not define.',
// Exam certification catalogue.
rank_not_found:
'That STCW rank is not in the ranks registry, or has been switched off. Pick a rank from the list.',
certification_in_use:
'Exam sessions or question-bank items still use this certification. Reassign or remove those first.',
}; };
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string { export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {

View File

@@ -277,6 +277,39 @@ export interface LicenseType {
prerequisiteLicenseKeys?: string[] | null; prerequisiteLicenseKeys?: string[] | null;
} }
/**
* Body of `POST /license-types` — the row itself, before behaviour, fees, the
* form or its document slots are configured.
*
* Mirrors `CreateLicenseTypeDto` field for field. Create the type inactive: an
* active type with no form schema and no document requirements is immediately
* visible to applicants.
*/
export interface LicenseTypeCreate {
key: string;
name: Bilingual;
description?: Bilingual;
category?: LicenseCategory;
/** Omitted, the server derives it from `key` — see `FamilyKind`. */
familyKind?: FamilyKind;
/** Licence or registration; splits the catalogues without splitting storage. */
serviceKind?: ServiceKind;
/** Which transition set the application runs. */
workflowProfile?: WorkflowProfile;
certificatePrefix: string;
feeNewApplication?: number | null;
feeRenewal?: number | null;
feeCurrency?: string;
capitalThreshold?: number | null;
validityMonths?: number;
inspectionRequired?: boolean;
issuesCertificate?: boolean;
renewalEnabled?: boolean;
formSchema?: { sections: FormSectionConfig[] };
sortOrder?: number;
isActive?: boolean;
}
/** What kind of document a certificate type produces. */ /** What kind of document a certificate type produces. */
export type CertificateCategory = export type CertificateCategory =
"COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL"; "COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL";
@@ -487,6 +520,15 @@ export interface ApplicationRemark {
remark: string; remark: string;
isResolved: boolean; isResolved: boolean;
resolvedAt: string | null; resolvedAt: string | null;
/**
* When the applicant actually changed the value this remark points at.
*
* `resolvedAt` only says the item was ticked off — the portal bulk-resolves
* every remark of the round right before resubmitting, so it proves nothing.
* This is stamped by the section save or document re-upload itself, which
* makes it the only trustworthy answer to "what did they actually correct?".
*/
valueChangedAt: string | null;
createdAt: string; createdAt: string;
} }
@@ -960,3 +1002,121 @@ export interface ExamStateView {
/** Whether the exam engine produced the mark, or a person did. */ /** Whether the exam engine produced the mark, or a person did. */
autoGraded: boolean | null; autoGraded: boolean | null;
} }
export interface ApplicantDashboardSummary {
counts: {
totalApplications: number;
activeLicenses: number;
expiringSoonLicenses: number;
pendingApplications: number;
approvedApplications: number;
draftApplications: number;
actionRequiredApplications: number;
};
statusDistribution: Array<{
name: string;
value: number;
color: string;
}>;
monthlyTrend: Array<{
month: string;
year: number;
submitted: number;
approved: number;
}>;
categoryBreakdown: Array<{
category: string;
count: number;
}>;
}
export interface AdminDashboardAnalytics {
kpis: {
totalApplications: number;
unclaimedQueue: number;
assignedToMe: number;
inProgressTotal: number;
needsApplicant: number;
awaitingPayment: number;
approved: number;
rejected: number;
overdueSla: number;
withinSla: number;
slaComplianceRate: number;
activeLicenses: number;
revenue: {
totalCollected: number;
pendingAmount: number;
currency: string;
};
};
statusDistribution: Array<{
name: string;
value: number;
color: string;
}>;
monthlyTrend: Array<{
month: string;
year: number;
submitted: number;
approved: number;
rejected: number;
revenue: number;
}>;
categoryBreakdown: Array<{
category: string;
count: number;
percentage: number;
}>;
departmentSummary: {
seafarerRegistrations: {
total: number;
submitted: number;
approved: number;
};
seamanBooks: {
total: number;
pending: number;
issued: number;
};
btc: {
total: number;
pending: number;
issued: number;
};
vessels: {
total: number;
active: number;
pending: number;
};
};
oldestUnclaimed: Array<{
id: string;
applicationNumber: string;
companyName: string;
licenseTypeName: string;
category: string;
status: string;
submittedAt: string;
slaHours: number | null;
}>;
urgentApplications: Array<{
id: string;
applicationNumber: string;
companyName: string | null;
licenseTypeName: string;
category: string;
submittedAt: string | null;
hoursLeft: number;
isOverdue: boolean;
}>;
officerWorkload: Array<{
officerId: string;
officerName: string;
activeCount: number;
overdueCount: number;
onScheduleCount: number;
}>;
period?: string;
}

View File

@@ -39,6 +39,17 @@ export interface SeafarerDocument {
issuedAt: string | null; issuedAt: string | null;
rejectionReason: string | null; rejectionReason: string | null;
createdAt: string; createdAt: string;
/**
* Policy from the document's licence-type row, as configured on the
* backoffice Behaviour tab. Present on `/seafarer-documents/mine` so the
* portal shows the pickup step only for a type that has one, and offers
* Renew / Replace only inside the configured window — the same flags
* `/licenses/mine` carries for licences. Absent on older servers.
*/
requiresIssuanceScheduling?: boolean;
daysUntilExpiry?: number | null;
renewable?: boolean;
reissuable?: boolean;
} }
export interface SeafarerDocumentApplicant { export interface SeafarerDocumentApplicant {