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.
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
// only where it is already what the type is, rather than as an option whose
// 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
// 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
// stores as a zero its own validators then refuse (`validityMonths` has a
// floor of 6, `renewalWindowDays` of 1). Echoing those back is what made
// stores as a zero its own validators then refuse (`validityMonths` and
// `renewalWindowDays` both have a floor of 1). Echoing those back is what made
// saving an ownership transfer fail outright.
const {
validityMonths,
@@ -152,7 +152,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
} = draft;
// 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.
const term = expires
? {
@@ -345,8 +345,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
else set('validityMonths', next);
}}
// Matches the server's ranges, so the box cannot offer a value the
// save would reject: 13650 days, or 6240 months.
min={draft.validityDays !== null ? 1 : 6}
// save would reject: 13650 days, or 1240 months.
min={1}
max={draft.validityDays !== null ? 3650 : 240}
allowNegative={false}
disabled={!canEdit}
@@ -388,7 +388,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
patch({
validityDays: null,
validityMonths:
draft.validityMonths >= 6 ? draft.validityMonths : 12,
draft.validityMonths >= 1 ? draft.validityMonths : 12,
});
// No expiry means no renewal policy: clear it here rather than
// 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 {
Alert,
Button,
Checkbox,
Divider,
Drawer,
Group,
MultiSelect,
NumberInput,
SegmentedControl,
@@ -13,10 +14,11 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { IconCheck, IconDownload, IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import {
useGetPersonalDocumentsQuery,
useLocalized,
type ApplicationKind,
type DocumentRequirement,
@@ -74,7 +76,7 @@ const MIME_OPTIONS = [
/** What a new slot accepts until someone widens it. */
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
export type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
/**
* Which licences a personal document is asked for.
@@ -117,6 +119,7 @@ export function DocumentRequirementEditorDrawer({
opened,
onClose,
requirement,
initialDraft,
defaultApplicationKind,
onSave,
palette,
@@ -130,6 +133,8 @@ export function DocumentRequirementEditorDrawer({
onClose: () => void;
/** Null = adding a new requirement. */
requirement: DocumentRequirement | null;
/** Optional initial prefill when adding a new requirement (e.g. from personal docs). */
initialDraft?: Partial<DraftRequirement> | null;
defaultApplicationKind: ApplicationKind;
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
palette: FormSchemaPalette | undefined;
@@ -142,7 +147,7 @@ export function DocumentRequirementEditorDrawer({
/** The licence types this document is already scoped to. */
scope?: PersonalScope;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const localized = useLocalized();
const [draft, setDraft] = useState<DraftRequirement>(
emptyDraft(defaultApplicationKind, personal),
@@ -150,36 +155,84 @@ export function DocumentRequirementEditorDrawer({
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
const [keyError, setKeyError] = useState<string | null>(null);
const [selectedPersonalKey, setSelectedPersonalKey] = useState<string | null>(null);
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(() => {
if (opened) {
setDraft(
requirement
? {
key: requirement.key,
name: { ...requirement.name },
description: requirement.description ? { ...requirement.description } : undefined,
applicationKind: requirement.applicationKind,
mode: requirement.mode,
conditionExpression: requirement.conditionExpression,
allowedMimeTypes: requirement.allowedMimeTypes,
maxSizeMb: requirement.maxSizeMb,
requiresValidityDates: requirement.requiresValidityDates,
allowMultiple: requirement.allowMultiple,
isPersonal: requirement.isPersonal ?? personal,
maxFiles: requirement.maxFiles ?? null,
sortOrder: requirement.sortOrder,
}
: emptyDraft(defaultApplicationKind, personal),
);
if (requirement) {
setDraft({
key: requirement.key,
name: { ...requirement.name },
description: requirement.description ? { ...requirement.description } : undefined,
applicationKind: requirement.applicationKind,
mode: requirement.mode,
conditionExpression: requirement.conditionExpression,
allowedMimeTypes: requirement.allowedMimeTypes,
maxSizeMb: requirement.maxSizeMb,
requiresValidityDates: requirement.requiresValidityDates,
allowMultiple: requirement.allowMultiple,
isPersonal: requirement.isPersonal ?? personal,
maxFiles: requirement.maxFiles ?? null,
sortOrder: requirement.sortOrder,
});
setSelectedPersonalKey(null);
} else if (initialDraft) {
setDraft({
...emptyDraft(defaultApplicationKind, personal),
...initialDraft,
});
setSelectedPersonalKey(initialDraft.key ?? null);
} else {
setDraft(emptyDraft(defaultApplicationKind, personal));
setSelectedPersonalKey(null);
}
setScopeIds(scope);
setAppliesToAll(scope.length === 0);
setKeyError(null);
}
// `scope` is a fresh array each render; the opened flag is what gates this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, requirement, defaultApplicationKind, personal]);
}, [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() {
if (!draft.key.trim()) {
@@ -241,6 +294,40 @@ export function DocumentRequirementEditorDrawer({
</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
label={t('certReq.doc.key', 'Key')}
placeholder="bank_letter"

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
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 { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
import {
@@ -18,7 +18,11 @@ import {
import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
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. */
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
@@ -54,6 +58,8 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
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 requirements = useMemo(
@@ -120,14 +126,27 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
<Card key={kind} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditorState({ kind, requirement: null })}
>
{t('certReq.doc.add', 'Add document requirement')}
</Button>
<Group gap="xs">
<Button
size="xs"
variant="outline"
leftSection={<IconDownload size={13} />}
onClick={() => setImportModalKind(kind)}
>
{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>
{rows.length === 0 ? (
@@ -180,8 +199,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
<DocumentRequirementEditorDrawer
opened={editorState !== null}
onClose={() => setEditorState(null)}
onClose={() => {
setEditorState(null);
setInitialDraft(null);
}}
requirement={editorState?.requirement ?? null}
initialDraft={initialDraft}
defaultApplicationKind={editorState?.kind ?? 'NEW'}
onSave={handleSave}
palette={palette}
@@ -189,6 +212,28 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
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">
<Stack gap="md">
<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 {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 { useTranslation } from 'react-i18next';
import {IconPlus} from '@tabler/icons-react';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage, useGetRanksQuery, useLocalized } from '@ema-platform/api';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
@@ -15,6 +15,15 @@ import { type Certification } from '../../types/certification';
import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions';
interface CertificationFormValues {
nameEn: string;
nameAm: string;
descEn: string;
descAm: string;
rankKey: string | null;
isActive: boolean;
}
function CertificationForm({
editing,
rankOptions,
@@ -25,7 +34,7 @@ function CertificationForm({
editing: Certification | null;
rankOptions: { value: string; label: string }[];
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;
}) {
const { t } = useTranslation();
@@ -34,14 +43,15 @@ function CertificationForm({
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
const [isActive, setIsActive] = useState(editing?.isActive ?? true);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!nameEn || !nameAm) {
notify.error('Name fields are required');
if (!nameEn.trim() || !nameAm.trim()) {
notify.error(t('certification.validation.nameRequired', 'Both English and Amharic names are required'));
return;
}
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
onSubmit({ nameEn: nameEn.trim(), nameAm: nameAm.trim(), descEn, descAm, rankKey, isActive }, !!editing);
};
return (
@@ -63,6 +73,18 @@ function CertificationForm({
clearable
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>
<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>
@@ -76,8 +98,10 @@ function CertificationForm({
export function CertificationPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
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: rankRes } = useGetRanksQuery();
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
@@ -98,14 +122,14 @@ export function CertificationPage() {
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 description = { en: values.descEn, am: values.descAm };
try {
if (isEdit && editing) {
// null clears a previously-set rank; undefined would leave it
// 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'));
} else {
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
@@ -113,7 +137,7 @@ export function CertificationPage() {
}
resetForm();
} catch (e) {
handleError(e);
showError(e);
}
};
@@ -125,7 +149,7 @@ export function CertificationPage() {
closeDelete();
setDeleteTarget(null);
} catch (e) {
handleError(e);
showError(e);
}
};
@@ -184,7 +208,7 @@ export function CertificationPage() {
</Card>
<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>
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</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,
IconHash,
IconInfoCircle,
IconLicense,
IconAnchor,
IconId,
} from "@tabler/icons-react";
@@ -41,6 +42,7 @@ import { LocationPage } from "../../../location/pages/LocationPage";
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import { LicenseTypesTab } from "../../components/LicenseTypesTab";
import { RankDepartmentTab } from "./RankDepartmentTab";
import {
useGetOrganizationsQuery,
@@ -398,6 +400,9 @@ export function ConfigurationPage() {
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
{t("location.title")}
</Tabs.Tab>
<Tabs.Tab value="licenseTypes" leftSection={<IconLicense size={16} />}>
{t("configuration.licenseTypesTab", "Licence Types")}
</Tabs.Tab>
<Tabs.Tab
value="certifications"
leftSection={<IconCertificate size={16} />}
@@ -427,6 +432,10 @@ export function ConfigurationPage() {
<CertificationPage />
</Tabs.Panel>
<Tabs.Panel value="licenseTypes" pt="md">
<LicenseTypesTab />
</Tabs.Panel>
<Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab />
</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 type { AdvancedColumn } from '@ema-platform/ui';
import { Badge, Group, Text } from '@mantine/core';
import { WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -9,23 +9,55 @@ import {
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
{
header: 'Number',
header: 'Application #',
cell: ({ row }) => (
<Text size="sm" fw={500}>
<Text size="sm" fw={600} c="blue">
{row.original.applicationNumber}
</Text>
),
},
{
header: 'Company',
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
header: 'Company / Applicant',
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',
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}>
{STATUS_LABELS[row.original.status as LicenseStatus]}
</Badge>
),
cell: ({ row }) => {
const status = row.original.status as LicenseStatus;
return (
<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 { 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 {
IconAlertTriangle,
IconCash,
IconCertificate,
IconChevronRight,
IconClock,
IconClockExclamation,
IconCreditCard,
IconDownload,
IconFileText,
IconFolders,
IconInbox,
IconRefresh,
IconShip,
IconUserCheck,
IconUsers,
} from '@tabler/icons-react';
import {
STATUS_COLORS,
STATUS_LABELS,
useGetAdminDashboardAnalyticsQuery,
useGetAssignedToMeQuery,
useGetQueueQuery,
useGetVesselsQuery,
useListSeafarerDocumentsQuery,
useListSeafarerRegistrationsQuery,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
import {
AdvancedTable,
@@ -20,178 +54,797 @@ import {
PageLoader,
StatTile,
WaitingFor,
notify,
useServerTable,
} from '@ema-platform/ui';
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() {
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 mine = useGetAssignedToMeQuery();
const table = useServerTable();
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
// Department queues
const registrations = useListSeafarerRegistrationsQuery({
status: 'SUBMITTED',
take: 1,
});
const seamanBooks = useListSeafarerDocumentsQuery({
kind: 'SEAMAN_BOOK',
status: 'PAYMENT_PENDING',
take: 1,
});
const btcDocuments = useListSeafarerDocumentsQuery({
kind: 'BTC_BASIC_TRAINING',
status: 'PAYMENT_PENDING',
take: 1,
});
const vesselsQuery = useGetVesselsQuery({ search: '' });
if (queue.isLoading || mine.isLoading) {
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
}
const analytics = analyticsQuery.data;
// Real data arrays from queries
const unclaimed = queue.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;
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
// Fallback counts if analytics is loading
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 }) =>
q.isError ? undefined : q.data?.total;
const show = (n: number | undefined) => (n === undefined ? '—' : n);
return (
<Stack gap="lg">
<PageHeader
title="Dashboard"
subtitle="Work waiting across the Authority's review queues."
noMargin
/>
// KPIs merging backend analytics with client queries for instant accuracy
const unclaimedCount = analytics?.kpis.unclaimedQueue ?? unclaimed.length;
const assignedToMeCount = analytics?.kpis.assignedToMe ?? inProgress.length;
const inProgressTotalCount =
analytics?.kpis.inProgressTotal ?? inProgress.length;
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
label="Awaiting claim"
value={unclaimed.length}
hint="Licence applications nobody has picked up"
label="Awaiting Claim"
value={unclaimedCount}
hint="Unclaimed pool in queue"
icon={IconInbox}
tone="info"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Assigned to me"
value={inProgress.length}
hint="Your open licence reviews"
label="Assigned to Me"
value={assignedToMeCount}
hint="Your claimed active reviews"
icon={IconFileText}
tone="neutral"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Needs applicant action"
value={needsApplicant}
hint="Returned for corrections"
label="In Review (All)"
value={inProgressTotalCount}
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}
tone="pending"
/>
<StatTile
label="Awaiting payment"
value={awaitingPayment}
hint="Approved, fee not yet settled"
label="Awaiting Payment"
value={awaitingPaymentCount}
hint="Approved, fee unsettled"
icon={IconCreditCard}
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
label="Seafarer registrations"
value={show(countOf(registrations))}
hint="Submitted, awaiting review"
icon={IconUserCheck}
tone="info"
onClick={() => navigate('/seafarer-registrations')}
/>
<StatTile
label="Seaman books"
value={show(countOf(seamanBooks))}
hint="Released, awaiting payment"
icon={IconCreditCard}
tone="pending"
onClick={() => navigate('/seaman-book-queue')}
label="SLA Overdue"
value={overdueSlaCount}
hint={overdueSlaCount > 0 ? 'Urgent attention required' : 'All within SLA window'}
icon={IconClockExclamation}
tone={overdueSlaCount > 0 ? 'danger' : 'neutral'}
onClick={() => navigate('/licence-review')}
/>
</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">
{/* Main Worklist: Oldest Unclaimed Pool or SLA Critical */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<AdvancedTable<LicenseApplication>
title="Awaiting claim — oldest first"
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
refresh={queue.refetch}
isLoading={queue.isFetching}
emptyText="Nothing waiting to be claimed."
toolbar={
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
Open queue
</Anchor>
}
/>
<Card withBorder radius="lg" p="md" h="100%">
<Tabs
value={activeTab}
onChange={setActiveTab}
variant="pills"
radius="md"
>
<Group justify="space-between" mb="md" wrap="nowrap">
<Tabs.List>
<Tabs.Tab
value="unclaimed"
leftSection={<IconInbox size={15} />}
>
Unclaimed Work Pool ({byAge.length})
</Tabs.Tab>
<Tabs.Tab
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>
{/* Side Panel: Longest Waiting & Fast Directory */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Paper withBorder radius="lg" p="lg" h="100%">
<Text fw={600} size="sm" mb="xs">
Longest waiting
</Text>
<Text size="xs" c="dimmed" mb="md">
Unclaimed applications, by how long they have sat.
</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.
<Stack gap="md" h="100%">
{/* Longest Waiting Applications Card */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
Longest Waiting Applications
</Text>
)}
</Stack>
</Paper>
<IconClock size={16} color="var(--mantine-color-dimmed)" />
</Group>
<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>
</Stack>

View File

@@ -12,6 +12,7 @@ import {
IconFileUpload,
IconMessage,
IconArrowRight,
IconPencilCheck,
IconUserCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
@@ -19,10 +20,16 @@ import {
STATUS_COLORS,
STATUS_LABELS,
type ApplicationDetail,
type ApplicationRemark,
} from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
type EntryKind =
| 'status'
| 'remark'
| 'correction'
| 'upload'
| 'assignment';
interface ActivityEntry {
id: string;
@@ -37,6 +44,7 @@ interface ActivityEntry {
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
status: IconArrowRight,
remark: IconMessage,
correction: IconPencilCheck,
upload: IconFileUpload,
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
* 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 showDate = useDateDisplayer();
@@ -82,18 +101,37 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
}
for (const remark of detail.remarks ?? []) {
const target = remarkLabel?.(remark) ?? remark.targetKey;
merged.push({
id: `remark-${remark.id}`,
kind: 'remark',
at: remark.createdAt,
actor: t('review.activity.officer', 'Officer'),
title: t('review.activity.remarkOn', {
target: remark.targetKey,
target,
defaultValue: 'Correction requested on {{target}}',
}),
detail: remark.remark,
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 ?? []) {
@@ -118,7 +156,7 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
return merged.sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
);
}, [detail, t]);
}, [detail, remarkLabel, t]);
if (entries.length === 0) {
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,
IconEye,
IconFileText,
IconPencilCheck,
IconRotate,
IconX,
} from "@tabler/icons-react";
@@ -43,6 +44,11 @@ interface DocumentsTabProps {
requirements: DocumentRequirement[];
/** Applicant answers used to evaluate conditional document requirements. */
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. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
@@ -63,6 +69,7 @@ export function DocumentsTab({
attachments,
requirements,
formData,
correctedKeys,
flags,
onToggleFlag,
onFlagRemark,
@@ -243,6 +250,16 @@ export function DocumentsTab({
{t("review.documents.flagged", "Correction requested")}
</Badge>
)}
{correctedKeys?.has(attachment.documentKey) && (
<Badge
color="blue"
variant="light"
size="sm"
leftSection={<IconPencilCheck size={11} />}
>
{t("review.corrected.badge", "Corrected")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ??

View File

@@ -9,7 +9,11 @@ import {
TextInput,
Tooltip,
} from '@mantine/core';
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
import {
IconAlertTriangle,
IconMapPin,
IconPencilCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
conditionHolds,
@@ -34,6 +38,12 @@ interface FormDetailsTabProps {
/** The licence type's form schema — the order and labels to render by. */
configSections: FormSectionConfig[];
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. */
flags: Record<string, { remark: string }>;
onToggleFlag: (sectionKey: string) => void;
@@ -63,6 +73,7 @@ export function FormDetailsTab({
formData,
configSections,
currency,
correctedKeys,
flags,
onToggleFlag,
onFlagRemark,
@@ -145,6 +156,16 @@ export function FormDetailsTab({
<Text fw={600} size="sm">
{section.title}
</Text>
{correctedKeys?.has(section.key) && (
<Badge
size="xs"
color="blue"
variant="light"
leftSection={<IconPencilCheck size={10} />}
>
{t('review.corrected.badge', 'Corrected')}
</Badge>
)}
{missing > 0 && (
<Tooltip
label={t(

View File

@@ -9,36 +9,73 @@ const REASONS = {
needsFlags: "needs flags",
needsCapital: "needs capital",
needsInspection: "needs inspection",
inspectionFailed: "inspection failed",
needsDocumentReviews: "needs document reviews",
inspectionNotYetDue: "not yet due",
};
/** 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 {
application: {
id: "app-1",
status: "SUBMITTED",
assignedOfficerId: "another-officer",
licenseType: { key: licenseTypeKey },
licenseType:
certificateCategory === undefined
? { key: licenseTypeKey }
: { key: licenseTypeKey, certificateCategory },
},
availableEvents: ["claim", "assign", "request-adjustment"],
} as unknown as ApplicationDetail;
}
function resolve(licenseTypeKey: string) {
function resolve(licenseTypeKey: string, certificateCategory?: string | null) {
return resolveActions({
detail: detailFor(licenseTypeKey),
detail: detailFor(licenseTypeKey, certificateCategory),
currentUserId: "me",
can: () => true,
reasons: REASONS,
flaggedCount: 1,
hasPendingInspection: false,
inspectionNotYetDue: false,
latestInspectionPassed: null,
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", () => {
it("drops claim and assign for a CoC", () => {
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?.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 { isSeafarerCertificate } from './license-types';
import {
isSeafarerCertificate,
requiresPassedInspection,
} from './license-types';
/**
* Where an action is rendered. One tier per action, decided here rather than
@@ -347,6 +350,7 @@ export interface ResolveContext {
needsFlags: string;
needsCapital: string;
needsInspection: string;
inspectionFailed: string;
needsDocumentReviews: string;
inspectionNotYetDue: string;
};
@@ -360,6 +364,18 @@ export interface ResolveContext {
* button waits (the server refuses early results the same way).
*/
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
* 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
// 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.
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>(
(action) => {
@@ -513,6 +533,35 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
) {
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 };

View File

@@ -10,7 +10,11 @@ import {
IconUsers,
type Icon,
} 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.
@@ -152,12 +156,33 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
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
* than an organisation's licence. They skip the claim/assign queue — see
* CoC, CoP and the GOC family: a seafarer's own certificate rather than an
* organisation's licence. They skip the claim/assign queue — see
* `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;
return (
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. */
export function presentationFor(key: string | undefined): LicenseTypePresentation {
if (key && PRESENTATION[key]) return PRESENTATION[key];

View File

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

View File

@@ -72,6 +72,7 @@ import {
useRescheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation,
uploadDocument,
type ApplicationRemark,
type RemarkTargetType,
type StaffEvidenceRequirement,
} from "@ema-platform/api";
@@ -92,6 +93,7 @@ import {
type DecisionSubmission,
} from "../../components/DecisionConfirmModal";
import { ActivityRail } from "../../components/ActivityRail";
import { CorrectedItemsPanel } from "../../components/CorrectedItemsPanel";
import { DocumentsTab } from "../../components/DocumentsTab";
import { FormDetailsTab } from "../../components/FormDetailsTab";
import { ApplicantCard } from "../../components/ApplicantCard";
@@ -102,6 +104,7 @@ import { reviewStaffColumns } from "./columns";
import {
evaluateEligibility,
presentationFor,
requiresPassedInspection,
} from "../../config/license-types";
import {
resolveActions,
@@ -331,6 +334,40 @@ export function LicenseReviewPage() {
) < 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 } =
useGetAttachmentsQuery(
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
@@ -384,6 +421,100 @@ export function LicenseReviewPage() {
[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(() => {
if (!data) return [];
return resolveActions({
@@ -393,6 +524,7 @@ export function LicenseReviewPage() {
flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection),
inspectionNotYetDue,
latestInspectionPassed,
allDocumentsAccepted,
reasons: {
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'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
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', {
date: 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
// client-side from the same list, so the reviewer reads the place rather than
@@ -902,9 +1050,8 @@ export function LicenseReviewPage() {
style={{ cursor: "pointer" }}
onClick={() => navigate(`/licence-review/${related.id}`)}
>
{related.licenseType?.key === "SEAMAN_BOOK"
? "Seaman Book"
: "BTC"}{" "}
{localized(related.licenseType?.name) ||
related.licenseType?.key}{" "}
· {related.applicationNumber} ·{" "}
{STATUS_LABELS[related.status]}
</Badge>
@@ -935,6 +1082,12 @@ export function LicenseReviewPage() {
</Group>
</Group>
<CorrectedItemsPanel
round={correction.round}
items={correction.corrected}
untouched={correction.untouched}
/>
<Grid>
{/* Zone 1 — sticky summary rail. */}
<Grid.Col span={{ base: 12, md: 3 }}>
@@ -1130,6 +1283,7 @@ export function LicenseReviewPage() {
formData={app.formData ?? {}}
configSections={configSections}
currency={app.feeCurrency ?? undefined}
correctedKeys={correction.sectionKeys}
flags={flags}
onToggleFlag={(sectionKey) =>
toggleFlag("FORM_SECTION", sectionKey)
@@ -1205,6 +1359,7 @@ export function LicenseReviewPage() {
attachments={data.attachments}
requirements={requirements?.documentRequirements ?? []}
formData={app.formData ?? {}}
correctedKeys={correction.documentKeys}
flags={documentFlags}
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
onFlagRemark={(key, remark) =>
@@ -1330,7 +1485,8 @@ export function LicenseReviewPage() {
{/* Page-level, not inside the inspection tab: a license type
configured without an inspection detail section must still show
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} />}>
{t(
"review.inspectionFailedBlocked",
@@ -1358,7 +1514,7 @@ export function LicenseReviewPage() {
{/* Zone 3 — activity and audit trail. */}
{railOpen && (
<Grid.Col span={{ base: 12, md: 3 }}>
<ActivityRail detail={data} />
<ActivityRail detail={data} remarkLabel={remarkLabel} />
</Grid.Col>
)}
</Grid>
@@ -1563,17 +1719,18 @@ export function LicenseReviewPage() {
[item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION",
}))
}
data={[
{ value: "PASS", label: t("review.checkPass", "Pass") },
{
value: "NEEDS_CORRECTION",
label: t("review.checkFix", "Fix"),
},
{ value: "FAIL", label: t("review.checkFail", "Fail") },
]}
data={checklistOptions}
/>
</Group>
))}
{passBlocked && (
<Text size="xs" c="dimmed">
{t(
"review.checklistMustPass",
"Every item must pass before the inspection can be recorded as passed.",
)}
</Text>
)}
</Stack>
<Textarea
label={t("review.findings", "Findings")}
@@ -1653,7 +1810,12 @@ export function LicenseReviewPage() {
variant="light"
color="teal"
size="lg"
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
disabled={
!findings.trim() ||
!pendingInspection ||
inspectionNotYetDue ||
passBlocked
}
aria-label={t("review.passed", "Passed")}
onClick={() =>
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;

View File

@@ -10,6 +10,7 @@ import {
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
useConfirmSeafarerDocumentPaymentMutation,
useGetLicenseTypesQuery,
useGetSeafarerDocumentReviewQuery,
useIssueSeafarerDocumentMutation,
useLazyGetSeafarerDocumentReviewDownloadQuery,
@@ -60,6 +61,9 @@ export function SeafarerDocumentReviewPage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
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 [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
@@ -90,6 +94,11 @@ export function SeafarerDocumentReviewPage() {
}
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 terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
@@ -156,12 +165,13 @@ export function SeafarerDocumentReviewPage() {
</Button>
</RequirePermission>
)}
{document.status === 'PAYMENT_CONFIRMED' && (
{document.status === 'PAYMENT_CONFIRMED' && collectedInPerson && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
</RequirePermission>
)}
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
{(document.status === 'SCHEDULED' ||
(document.status === 'PAYMENT_CONFIRMED' && !collectedInPerson)) && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
Issue

View File

@@ -104,10 +104,7 @@ const UM_CONFIG: DesignConfig = {
const UM_RUNTIME = {
basename: "/um",
// 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
// 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",
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "https://ema-api-dev.triaplc.com/api",
};
const buttonStyle: React.CSSProperties = {

View File

@@ -726,11 +726,19 @@ export const am: Translations = {
descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
descAm: "መግለጫ (አማርኛ)",
descAmPlaceholder: "የአማርኛ መግለጫ",
rankKey: "የSTCW ማዕረግ (ለፈተና መርሐግብር)",
rankKeyHint: "ይህ የምስክር ወረቀት የሚፈተነው የCoC/CoP መሰላል አካል ካልሆነ ባዶ ይተዉት።",
rankKeyPlaceholder: "ለተወሰነ ማዕረግ አይደለም",
isActive: "ንቁ",
isActiveHint: "ንቁ ያልሆኑ የምስክር ወረቀቶች በነባር ፈተናዎች ላይ ይቆያሉ፤ ለአዲስ ፈተናዎች ግን አይቀርቡም።",
},
status: {
active: "ንቁ",
inactive: "እንቅስቃሴ የሌለ",
},
validation: {
nameRequired: "የእንግሊዝኛ እና የአማርኛ ስሞች ሁለቱም ያስፈልጋሉ",
},
},
result: {
@@ -1005,6 +1013,88 @@ export const am: Translations = {
configuration: {
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: "የግል ሰነዶች",
departments: "ክፍሎች",
professions: "ሙያዎች",
@@ -1040,6 +1130,7 @@ export const am: Translations = {
},
},
queue: {
title: "የፈቃድ ማመልከቻዎች",
titleByFamily: "{{family}} ማመልከቻዎች",
@@ -1160,6 +1251,15 @@ export const am: Translations = {
deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።",
notificationPreview: "ለአመልካቹ የሚላክ መልእክት",
notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።",
corrected: {
title_one: "በዙር {{round}} 1 ነጥብ ተስተካክሏል",
title_other: "በዙር {{round}} {{count}} ነጥቦች ተስተካክለዋል",
untouchedBadge_one: "1 አልተለወጠም",
untouchedBadge_other: "{{count}} አልተለወጡም",
untouched: "እንደተጠናቀቀ ምልክት ተደርጎ ግን አልተለወጠም — መጀመሪያ እነዚህን ይመልከቱ",
against: "የተጠየቀው፦ {{remark}}",
badge: "ተስተካክሏል",
},
needsCorrection: "ማስተካከያ ያስፈልገዋል",
correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?",
verifiedCapital: "የተረጋገጠ ካፒታል (ብር)",
@@ -1242,6 +1342,7 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
inspectionFailed: "ምርመራው አልተሳካም — ከመጽደቁ በፊት ድጋሚ ምርመራ ማለፍ አለበት",
inspectionNotYetDue:
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
needsDocumentReviews:
@@ -1249,7 +1350,9 @@ export const am: Translations = {
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
},
inspectionFailedBlocked:
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
"ምርመራው ስላልተሳካ ማጽደቅና ሰርተፍኬት መስጠት አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
checklistMustPass:
"ምርመራው እንደተሳካ ከመመዝገቡ በፊት ሁሉም ነጥቦች ማለፍ አለባቸው።",
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
@@ -1295,6 +1398,7 @@ export const am: Translations = {
officer: "ሹም",
applicant: "አመልካች",
remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል",
correctedTarget: "{{target}} ተስተካክሏል",
uploaded: "{{document}} ተጭኗል",
},
documents: {
@@ -1657,6 +1761,21 @@ export const am: Translations = {
maxFilesUnlimited: "ገደብ የለም",
sortOrder: "የቅደም ተከተል ቁጥር",
when: "መቼ",
importPersonal: "ከግል ሰነዶች አስመጣ",
importPersonalDesc:
"ለዚህ የፈቃድ አይነት እንደ መስፈርት ለማከል በውቅር ገጽ ላይ ከተዋቀሩት የግል ሰነዶች ይምረጡ።",
importFromPersonal: "ከግል ሰነድ አስመጣ",
selectPersonalDoc: "ዝርዝሩን ለመቅዳት የግል ሰነድ ይምረጡ…",
importSelected: "የተመረጡትን አስመጣ ({{count}})",
importAction: "አስመጣ",
customizeAndAdd: "አስተካክለህ ጨምር",
alreadyAdded: "ቀደም ሲል ተጨምሯል",
noPersonalDocs: "በውቅር ውስጥ የተዘጋጀ የግል ሰነድ እስካሁን የለም።",
importedFromPersonal: "ከግል ሰነድ የመጣ: {{name}}",
importedHelper:
"ከግል ሰነዶች የመጣ። ቁልፉ ከአመልካቹ ሰነዶች ጋር ስለሚዛመድ ፋይሎች በቀጥታ ይገናኛሉ።",
importMode: "ለሚመጡ ሰነዶች የመስፈርት ሁነታ",
importSuccess: "{{count}} የግል ሰነድ(ዶች) ገብተዋል",
},
personal: {
title: "የግል ሰነዶች",

View File

@@ -718,6 +718,13 @@ export const en = {
description: 'Description',
status: 'Status',
},
status: {
active: 'Active',
inactive: 'Inactive',
},
validation: {
nameRequired: 'Both English and Amharic names are required',
},
form: {
nameEn: 'Name (English)',
nameEnPlaceholder: 'Certificate name in English',
@@ -727,10 +734,11 @@ export const en = {
descEnPlaceholder: 'English description',
descAm: 'Description (Amharic)',
descAmPlaceholder: 'የአማርኛ መግለጫ',
},
status: {
active: 'Active',
inactive: 'Inactive',
rankKey: 'STCW rank (for exam scheduling)',
rankKeyHint: 'Leave blank if this certification is not part of the examined CoC/CoP ladder.',
rankKeyPlaceholder: 'Not rank-specific',
isActive: 'Active',
isActiveHint: 'Inactive certifications stay on existing exams but are not offered for new ones.',
},
},
@@ -1010,6 +1018,89 @@ export const en = {
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',
departments: 'Departments',
professions: 'Professions',
@@ -1170,6 +1261,15 @@ export const en = {
deficienciesHint: 'Only the ticked items become editable for the applicant.',
notificationPreview: 'Message to the applicant',
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',
correctionPlaceholder: 'What must the applicant correct?',
verifiedCapital: 'Verified capital (ETB)',
@@ -1252,6 +1352,8 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result',
inspectionFailed:
'The inspection failed — a re-inspection must pass before approval',
inspectionNotYetDue:
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
needsDocumentReviews:
@@ -1259,7 +1361,9 @@ export const en = {
needsDocumentsUploaded: 'No documents uploaded to review yet',
},
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: {
incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum',
@@ -1302,6 +1406,7 @@ export const en = {
officer: 'Officer',
applicant: 'Applicant',
remarkOn: 'Correction requested on {{target}}',
correctedTarget: 'Corrected {{target}}',
uploaded: 'Uploaded {{document}}',
},
documents: {
@@ -1664,6 +1769,21 @@ export const en = {
maxFilesUnlimited: 'No limit',
sortOrder: 'Sort order',
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: {
title: 'Personal documents',