mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
@@ -129,7 +129,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
// against an expiry that never arrives, so none of them are asked for.
|
// against an expiry that never arrives, so none of them are asked for.
|
||||||
const expires = draft.validityDays !== null || draft.validityMonths > 0;
|
const expires = draft.validityDays !== null || draft.validityMonths > 0;
|
||||||
|
|
||||||
// The server's floor for a stated term is 6 months, so no-expiry is a state
|
// The server's floor for a stated term is 1 month, so no-expiry is a state
|
||||||
// this form can hold and edit around but cannot switch a type into. Offered
|
// this form can hold and edit around but cannot switch a type into. Offered
|
||||||
// only where it is already what the type is, rather than as an option whose
|
// only where it is already what the type is, rather than as an option whose
|
||||||
// save would be refused.
|
// save would be refused.
|
||||||
@@ -140,8 +140,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
// Only the settings this form actually asked for. The endpoint patches, so
|
// Only the settings this form actually asked for. The endpoint patches, so
|
||||||
// an omitted field keeps its stored value — and the fields hidden above are
|
// an omitted field keeps its stored value — and the fields hidden above are
|
||||||
// hidden precisely because the type has no such policy, which the server
|
// hidden precisely because the type has no such policy, which the server
|
||||||
// stores as a zero its own validators then refuse (`validityMonths` has a
|
// stores as a zero its own validators then refuse (`validityMonths` and
|
||||||
// floor of 6, `renewalWindowDays` of 1). Echoing those back is what made
|
// `renewalWindowDays` both have a floor of 1). Echoing those back is what made
|
||||||
// saving an ownership transfer fail outright.
|
// saving an ownership transfer fail outright.
|
||||||
const {
|
const {
|
||||||
validityMonths,
|
validityMonths,
|
||||||
@@ -152,7 +152,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
} = draft;
|
} = draft;
|
||||||
|
|
||||||
// A type switched from "does not expire" straight to a term in days still
|
// A type switched from "does not expire" straight to a term in days still
|
||||||
// carries `validityMonths: 0`, which the server's 6-month floor refuses.
|
// carries `validityMonths: 0`, which the server's 1-month floor refuses.
|
||||||
// Days win over months at issuance, so the zero is simply left alone.
|
// Days win over months at issuance, so the zero is simply left alone.
|
||||||
const term = expires
|
const term = expires
|
||||||
? {
|
? {
|
||||||
@@ -345,8 +345,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
else set('validityMonths', next);
|
else set('validityMonths', next);
|
||||||
}}
|
}}
|
||||||
// Matches the server's ranges, so the box cannot offer a value the
|
// Matches the server's ranges, so the box cannot offer a value the
|
||||||
// save would reject: 1–3650 days, or 6–240 months.
|
// save would reject: 1–3650 days, or 1–240 months.
|
||||||
min={draft.validityDays !== null ? 1 : 6}
|
min={1}
|
||||||
max={draft.validityDays !== null ? 3650 : 240}
|
max={draft.validityDays !== null ? 3650 : 240}
|
||||||
allowNegative={false}
|
allowNegative={false}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -388,7 +388,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
|||||||
patch({
|
patch({
|
||||||
validityDays: null,
|
validityDays: null,
|
||||||
validityMonths:
|
validityMonths:
|
||||||
draft.validityMonths >= 6 ? draft.validityMonths : 12,
|
draft.validityMonths >= 1 ? draft.validityMonths : 12,
|
||||||
});
|
});
|
||||||
// No expiry means no renewal policy: clear it here rather than
|
// No expiry means no renewal policy: clear it here rather than
|
||||||
// save renewal settings that could never fire.
|
// save renewal settings that could never fire.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Group,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
@@ -13,10 +14,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconCheck, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
|
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
|
useGetPersonalDocumentsQuery,
|
||||||
useLocalized,
|
useLocalized,
|
||||||
type ApplicationKind,
|
type ApplicationKind,
|
||||||
type DocumentRequirement,
|
type DocumentRequirement,
|
||||||
@@ -74,7 +76,7 @@ const MIME_OPTIONS = [
|
|||||||
/** What a new slot accepts until someone widens it. */
|
/** What a new slot accepts until someone widens it. */
|
||||||
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||||
|
|
||||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
export type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which licences a personal document is asked for.
|
* Which licences a personal document is asked for.
|
||||||
@@ -117,6 +119,7 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
requirement,
|
requirement,
|
||||||
|
initialDraft,
|
||||||
defaultApplicationKind,
|
defaultApplicationKind,
|
||||||
onSave,
|
onSave,
|
||||||
palette,
|
palette,
|
||||||
@@ -130,6 +133,8 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Null = adding a new requirement. */
|
/** Null = adding a new requirement. */
|
||||||
requirement: DocumentRequirement | null;
|
requirement: DocumentRequirement | null;
|
||||||
|
/** Optional initial prefill when adding a new requirement (e.g. from personal docs). */
|
||||||
|
initialDraft?: Partial<DraftRequirement> | null;
|
||||||
defaultApplicationKind: ApplicationKind;
|
defaultApplicationKind: ApplicationKind;
|
||||||
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
|
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
|
||||||
palette: FormSchemaPalette | undefined;
|
palette: FormSchemaPalette | undefined;
|
||||||
@@ -142,7 +147,7 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
/** The licence types this document is already scoped to. */
|
/** The licence types this document is already scoped to. */
|
||||||
scope?: PersonalScope;
|
scope?: PersonalScope;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
const [draft, setDraft] = useState<DraftRequirement>(
|
const [draft, setDraft] = useState<DraftRequirement>(
|
||||||
emptyDraft(defaultApplicationKind, personal),
|
emptyDraft(defaultApplicationKind, personal),
|
||||||
@@ -150,36 +155,84 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
||||||
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
||||||
const [keyError, setKeyError] = useState<string | null>(null);
|
const [keyError, setKeyError] = useState<string | null>(null);
|
||||||
|
const [selectedPersonalKey, setSelectedPersonalKey] = useState<string | null>(null);
|
||||||
const isNew = !requirement;
|
const isNew = !requirement;
|
||||||
|
|
||||||
|
const { data: personalDocsData } = useGetPersonalDocumentsQuery(
|
||||||
|
{ take: 100, locale: i18n.language === 'am' ? 'am' : 'en' },
|
||||||
|
{ skip: !opened || !isNew || personal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const personalDocOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(personalDocsData?.items ?? []).map((item) => ({
|
||||||
|
value: item.key,
|
||||||
|
label: `${localized(item.rows[0]?.name) || item.key} (${item.key})`,
|
||||||
|
})),
|
||||||
|
[personalDocsData, localized],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opened) {
|
if (opened) {
|
||||||
setDraft(
|
if (requirement) {
|
||||||
requirement
|
setDraft({
|
||||||
? {
|
key: requirement.key,
|
||||||
key: requirement.key,
|
name: { ...requirement.name },
|
||||||
name: { ...requirement.name },
|
description: requirement.description ? { ...requirement.description } : undefined,
|
||||||
description: requirement.description ? { ...requirement.description } : undefined,
|
applicationKind: requirement.applicationKind,
|
||||||
applicationKind: requirement.applicationKind,
|
mode: requirement.mode,
|
||||||
mode: requirement.mode,
|
conditionExpression: requirement.conditionExpression,
|
||||||
conditionExpression: requirement.conditionExpression,
|
allowedMimeTypes: requirement.allowedMimeTypes,
|
||||||
allowedMimeTypes: requirement.allowedMimeTypes,
|
maxSizeMb: requirement.maxSizeMb,
|
||||||
maxSizeMb: requirement.maxSizeMb,
|
requiresValidityDates: requirement.requiresValidityDates,
|
||||||
requiresValidityDates: requirement.requiresValidityDates,
|
allowMultiple: requirement.allowMultiple,
|
||||||
allowMultiple: requirement.allowMultiple,
|
isPersonal: requirement.isPersonal ?? personal,
|
||||||
isPersonal: requirement.isPersonal ?? personal,
|
maxFiles: requirement.maxFiles ?? null,
|
||||||
maxFiles: requirement.maxFiles ?? null,
|
sortOrder: requirement.sortOrder,
|
||||||
sortOrder: requirement.sortOrder,
|
});
|
||||||
}
|
setSelectedPersonalKey(null);
|
||||||
: emptyDraft(defaultApplicationKind, personal),
|
} else if (initialDraft) {
|
||||||
);
|
setDraft({
|
||||||
|
...emptyDraft(defaultApplicationKind, personal),
|
||||||
|
...initialDraft,
|
||||||
|
});
|
||||||
|
setSelectedPersonalKey(initialDraft.key ?? null);
|
||||||
|
} else {
|
||||||
|
setDraft(emptyDraft(defaultApplicationKind, personal));
|
||||||
|
setSelectedPersonalKey(null);
|
||||||
|
}
|
||||||
setScopeIds(scope);
|
setScopeIds(scope);
|
||||||
setAppliesToAll(scope.length === 0);
|
setAppliesToAll(scope.length === 0);
|
||||||
setKeyError(null);
|
setKeyError(null);
|
||||||
}
|
}
|
||||||
// `scope` is a fresh array each render; the opened flag is what gates this.
|
// `scope` is a fresh array each render; the opened flag is what gates this.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [opened, requirement, defaultApplicationKind, personal]);
|
}, [opened, requirement, initialDraft, defaultApplicationKind, personal]);
|
||||||
|
|
||||||
|
function handleSelectPersonalDoc(key: string | null) {
|
||||||
|
setSelectedPersonalKey(key);
|
||||||
|
if (!key) return;
|
||||||
|
const found = personalDocsData?.items?.find((item) => item.key === key);
|
||||||
|
if (found && found.rows[0]) {
|
||||||
|
const row = found.rows[0];
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
key: found.key,
|
||||||
|
name: { en: row.name?.en ?? '', am: row.name?.am ?? '' },
|
||||||
|
description: row.description
|
||||||
|
? { en: row.description?.en ?? '', am: row.description?.am ?? '' }
|
||||||
|
: undefined,
|
||||||
|
allowedMimeTypes: row.allowedMimeTypes?.length
|
||||||
|
? [...row.allowedMimeTypes]
|
||||||
|
: d.allowedMimeTypes,
|
||||||
|
maxSizeMb: row.maxSizeMb ?? d.maxSizeMb,
|
||||||
|
maxFiles: row.maxFiles ?? null,
|
||||||
|
requiresValidityDates: row.requiresValidityDates ?? false,
|
||||||
|
allowMultiple: (row.maxFiles ?? 1) !== 1,
|
||||||
|
}));
|
||||||
|
setKeyError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
if (!draft.key.trim()) {
|
if (!draft.key.trim()) {
|
||||||
@@ -241,6 +294,40 @@ export function DocumentRequirementEditorDrawer({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!personal && isNew && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Select
|
||||||
|
label={t('certReq.doc.importFromPersonal', 'Import from personal document')}
|
||||||
|
placeholder={t(
|
||||||
|
'certReq.doc.selectPersonalDoc',
|
||||||
|
'Select a personal document to copy details…',
|
||||||
|
)}
|
||||||
|
data={personalDocOptions}
|
||||||
|
value={selectedPersonalKey}
|
||||||
|
onChange={handleSelectPersonalDoc}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
leftSection={<IconDownload size={15} />}
|
||||||
|
description={t(
|
||||||
|
'certReq.doc.importPersonalDesc',
|
||||||
|
'Select documents configured in Personal Documents to add as requirements for this licence type.',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedPersonalKey && (
|
||||||
|
<Alert variant="light" color="teal" icon={<IconCheck size={16} />}>
|
||||||
|
<Text fz="xs">
|
||||||
|
{t(
|
||||||
|
'certReq.doc.importedHelper',
|
||||||
|
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Divider my="xs" />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('certReq.doc.key', 'Key')}
|
label={t('certReq.doc.key', 'Key')}
|
||||||
placeholder="bank_letter"
|
placeholder="bank_letter"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
|
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
|
||||||
import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
import { IconAlertCircle, IconDownload, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
|
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
import { collectConditionTargets } from '../config/schema-paths';
|
import { collectConditionTargets } from '../config/schema-paths';
|
||||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||||
import { describeCondition } from './ConditionBuilder';
|
import { describeCondition } from './ConditionBuilder';
|
||||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
import {
|
||||||
|
DocumentRequirementEditorDrawer,
|
||||||
|
type DraftRequirement,
|
||||||
|
} from './DocumentRequirementEditorDrawer';
|
||||||
|
import { ImportPersonalDocumentsModal } from './ImportPersonalDocumentsModal';
|
||||||
|
|
||||||
/** Every kind the server validates a document set for, so each can be configured. */
|
/** Every kind the server validates a document set for, so each can be configured. */
|
||||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
|
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
|
||||||
@@ -54,6 +58,8 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||||
|
|
||||||
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
|
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
|
||||||
|
const [initialDraft, setInitialDraft] = useState<Partial<DraftRequirement> | null>(null);
|
||||||
|
const [importModalKind, setImportModalKind] = useState<ApplicationKind | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||||
|
|
||||||
const requirements = useMemo(
|
const requirements = useMemo(
|
||||||
@@ -120,14 +126,27 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
<Card key={kind} withBorder radius="md" p="md">
|
<Card key={kind} withBorder radius="md" p="md">
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
|
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
|
||||||
<Button
|
<Group gap="xs">
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
leftSection={<IconPlus size={13} />}
|
variant="outline"
|
||||||
onClick={() => setEditorState({ kind, requirement: null })}
|
leftSection={<IconDownload size={13} />}
|
||||||
>
|
onClick={() => setImportModalKind(kind)}
|
||||||
{t('certReq.doc.add', 'Add document requirement')}
|
>
|
||||||
</Button>
|
{t('certReq.doc.importPersonal', 'Import from personal documents')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPlus size={13} />}
|
||||||
|
onClick={() => {
|
||||||
|
setInitialDraft(null);
|
||||||
|
setEditorState({ kind, requirement: null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('certReq.doc.add', 'Add document requirement')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
@@ -180,8 +199,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
|
|
||||||
<DocumentRequirementEditorDrawer
|
<DocumentRequirementEditorDrawer
|
||||||
opened={editorState !== null}
|
opened={editorState !== null}
|
||||||
onClose={() => setEditorState(null)}
|
onClose={() => {
|
||||||
|
setEditorState(null);
|
||||||
|
setInitialDraft(null);
|
||||||
|
}}
|
||||||
requirement={editorState?.requirement ?? null}
|
requirement={editorState?.requirement ?? null}
|
||||||
|
initialDraft={initialDraft}
|
||||||
defaultApplicationKind={editorState?.kind ?? 'NEW'}
|
defaultApplicationKind={editorState?.kind ?? 'NEW'}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
@@ -189,6 +212,28 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
|||||||
saving={creating || updating}
|
saving={creating || updating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{importModalKind && (
|
||||||
|
<ImportPersonalDocumentsModal
|
||||||
|
opened={importModalKind !== null}
|
||||||
|
onClose={() => setImportModalKind(null)}
|
||||||
|
licenseType={licenseType}
|
||||||
|
applicationKind={importModalKind}
|
||||||
|
existingKeys={requirements
|
||||||
|
.filter((r) => r.applicationKind === importModalKind)
|
||||||
|
.map((r) => r.key)}
|
||||||
|
currentMaxSortOrder={requirements
|
||||||
|
.filter((r) => r.applicationKind === importModalKind)
|
||||||
|
.reduce((max, r) => Math.max(max, r.sortOrder ?? 0), 0)}
|
||||||
|
onCustomizeAndAdd={(draft) => {
|
||||||
|
setInitialDraft(draft);
|
||||||
|
setEditorState({
|
||||||
|
kind: (draft.applicationKind as ApplicationKind) ?? importModalKind,
|
||||||
|
requirement: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
|
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert color="yellow" variant="light">
|
<Alert color="yellow" variant="light">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
import { Stack, Button, Modal, Text, TextInput, Textarea, Select, Card, Switch } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {IconPlus} from '@tabler/icons-react';
|
import {IconPlus} from '@tabler/icons-react';
|
||||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||||
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
import { extractErrorMessage, useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
useGetCertificationsQuery,
|
useGetCertificationsQuery,
|
||||||
useCreateCertificationMutation,
|
useCreateCertificationMutation,
|
||||||
@@ -15,6 +15,15 @@ import { type Certification } from '../../types/certification';
|
|||||||
import { certificationColumns } from './columns';
|
import { certificationColumns } from './columns';
|
||||||
import { certificationActionsColumn } from './actions';
|
import { certificationActionsColumn } from './actions';
|
||||||
|
|
||||||
|
interface CertificationFormValues {
|
||||||
|
nameEn: string;
|
||||||
|
nameAm: string;
|
||||||
|
descEn: string;
|
||||||
|
descAm: string;
|
||||||
|
rankKey: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function CertificationForm({
|
function CertificationForm({
|
||||||
editing,
|
editing,
|
||||||
rankOptions,
|
rankOptions,
|
||||||
@@ -25,7 +34,7 @@ function CertificationForm({
|
|||||||
editing: Certification | null;
|
editing: Certification | null;
|
||||||
rankOptions: { value: string; label: string }[];
|
rankOptions: { value: string; label: string }[];
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
onSubmit: (values: CertificationFormValues, isEdit: boolean) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,14 +43,15 @@ function CertificationForm({
|
|||||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||||
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||||
|
const [isActive, setIsActive] = useState(editing?.isActive ?? true);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!nameEn || !nameAm) {
|
if (!nameEn.trim() || !nameAm.trim()) {
|
||||||
notify.error('Name fields are required');
|
notify.error(t('certification.validation.nameRequired', 'Both English and Amharic names are required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
onSubmit({ nameEn: nameEn.trim(), nameAm: nameAm.trim(), descEn, descAm, rankKey, isActive }, !!editing);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +73,18 @@ function CertificationForm({
|
|||||||
clearable
|
clearable
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
{editing && (
|
||||||
|
<Switch
|
||||||
|
label={t('certification.form.isActive', 'Active')}
|
||||||
|
description={t(
|
||||||
|
'certification.form.isActiveHint',
|
||||||
|
'Inactive certifications stay on existing exams but are not offered for new ones.',
|
||||||
|
)}
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.currentTarget.checked)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||||
@@ -76,8 +98,10 @@ function CertificationForm({
|
|||||||
export function CertificationPage() {
|
export function CertificationPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const { handleError } = useErrorHandler();
|
|
||||||
const localized = useLocalized();
|
const localized = useLocalized();
|
||||||
|
// Server refusals (`rank_not_found`, `certification_in_use`) arrive as
|
||||||
|
// codes; this maps them to the sentences the administrator can act on.
|
||||||
|
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
|
||||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||||
const { data: rankRes } = useGetRanksQuery();
|
const { data: rankRes } = useGetRanksQuery();
|
||||||
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||||
@@ -98,14 +122,14 @@ export function CertificationPage() {
|
|||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
const handleSubmit = async (values: CertificationFormValues, isEdit: boolean) => {
|
||||||
const name = { en: values.nameEn, am: values.nameAm };
|
const name = { en: values.nameEn, am: values.nameAm };
|
||||||
const description = { en: values.descEn, am: values.descAm };
|
const description = { en: values.descEn, am: values.descAm };
|
||||||
try {
|
try {
|
||||||
if (isEdit && editing) {
|
if (isEdit && editing) {
|
||||||
// null clears a previously-set rank; undefined would leave it
|
// null clears a previously-set rank; undefined would leave it
|
||||||
// untouched server-side, so the two are not interchangeable here.
|
// untouched server-side, so the two are not interchangeable here.
|
||||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey, isActive: values.isActive }).unwrap();
|
||||||
notify.success(t('certification.updated'));
|
notify.success(t('certification.updated'));
|
||||||
} else {
|
} else {
|
||||||
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||||
@@ -113,7 +137,7 @@ export function CertificationPage() {
|
|||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
showError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +149,7 @@ export function CertificationPage() {
|
|||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
showError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,7 +208,7 @@ export function CertificationPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget ? localized(deleteTarget.name) : '' })}</Text>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
||||||
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconLicense,
|
||||||
IconAnchor,
|
IconAnchor,
|
||||||
IconId,
|
IconId,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -41,6 +42,7 @@ import { LocationPage } from "../../../location/pages/LocationPage";
|
|||||||
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
|
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
|
||||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||||
|
import { LicenseTypesTab } from "../../components/LicenseTypesTab";
|
||||||
import { RankDepartmentTab } from "./RankDepartmentTab";
|
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||||
import {
|
import {
|
||||||
useGetOrganizationsQuery,
|
useGetOrganizationsQuery,
|
||||||
@@ -398,6 +400,9 @@ export function ConfigurationPage() {
|
|||||||
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
||||||
{t("location.title")}
|
{t("location.title")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="licenseTypes" leftSection={<IconLicense size={16} />}>
|
||||||
|
{t("configuration.licenseTypesTab", "Licence Types")}
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
value="certifications"
|
value="certifications"
|
||||||
leftSection={<IconCertificate size={16} />}
|
leftSection={<IconCertificate size={16} />}
|
||||||
@@ -427,6 +432,10 @@ export function ConfigurationPage() {
|
|||||||
<CertificationPage />
|
<CertificationPage />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="licenseTypes" pt="md">
|
||||||
|
<LicenseTypesTab />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="numberFormats" pt="md">
|
<Tabs.Panel value="numberFormats" pt="md">
|
||||||
<NumberFormatTab />
|
<NumberFormatTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Text } from '@mantine/core';
|
import { Badge, Group, Text } from '@mantine/core';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import { WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
@@ -9,23 +9,55 @@ import {
|
|||||||
|
|
||||||
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
|
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
|
||||||
{
|
{
|
||||||
header: 'Number',
|
header: 'Application #',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={600} c="blue">
|
||||||
{row.original.applicationNumber}
|
{row.original.applicationNumber}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Company',
|
header: 'Company / Applicant',
|
||||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{row.original.companyName || row.original.tradeName || '—'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'License Type',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
|
{row.original.licenseType?.name?.en ?? 'Maritime Service'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Waiting Since',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<WaitingFor
|
||||||
|
since={row.original.submittedAt ?? row.original.createdAt}
|
||||||
|
slaDays={
|
||||||
|
row.original.licenseType?.slaHours
|
||||||
|
? row.original.licenseType.slaHours / 24
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}>
|
const status = row.original.status as LicenseStatus;
|
||||||
{STATUS_LABELS[row.original.status as LicenseStatus]}
|
return (
|
||||||
</Badge>
|
<Badge
|
||||||
),
|
variant="light"
|
||||||
|
color={STATUS_COLORS[status] ?? 'gray'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconCash,
|
||||||
|
IconCertificate,
|
||||||
|
IconChevronRight,
|
||||||
|
IconClock,
|
||||||
|
IconClockExclamation,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
|
IconDownload,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
|
IconFolders,
|
||||||
IconInbox,
|
IconInbox,
|
||||||
|
IconRefresh,
|
||||||
|
IconShip,
|
||||||
IconUserCheck,
|
IconUserCheck,
|
||||||
|
IconUsers,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
|
STATUS_COLORS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
useGetAdminDashboardAnalyticsQuery,
|
||||||
useGetAssignedToMeQuery,
|
useGetAssignedToMeQuery,
|
||||||
useGetQueueQuery,
|
useGetQueueQuery,
|
||||||
|
useGetVesselsQuery,
|
||||||
useListSeafarerDocumentsQuery,
|
useListSeafarerDocumentsQuery,
|
||||||
useListSeafarerRegistrationsQuery,
|
useListSeafarerRegistrationsQuery,
|
||||||
type LicenseApplication,
|
type LicenseApplication,
|
||||||
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
AdvancedTable,
|
AdvancedTable,
|
||||||
@@ -20,178 +54,797 @@ import {
|
|||||||
PageLoader,
|
PageLoader,
|
||||||
StatTile,
|
StatTile,
|
||||||
WaitingFor,
|
WaitingFor,
|
||||||
|
notify,
|
||||||
useServerTable,
|
useServerTable,
|
||||||
} from '@ema-platform/ui';
|
} from '@ema-platform/ui';
|
||||||
import { dashboardQueueColumns } from './columns';
|
import { dashboardQueueColumns } from './columns';
|
||||||
|
import { DashboardCharts } from './DashboardCharts';
|
||||||
|
|
||||||
/**
|
|
||||||
* Backoffice home.
|
|
||||||
*
|
|
||||||
* Every figure here is counted from a queue the officer can open, and each
|
|
||||||
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
|
||||||
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
|
||||||
* series, and an earlier version of this page invented both a registration
|
|
||||||
* trend and a staff-role breakdown rather than admit that.
|
|
||||||
*
|
|
||||||
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
|
||||||
* queues are permission-gated and an officer without them simply gets no
|
|
||||||
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
|
||||||
* would be a lie.
|
|
||||||
*/
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>('unclaimed');
|
||||||
|
const [period, setPeriod] = useState<string>('6m');
|
||||||
|
|
||||||
|
// Primary platform analytics query with time window parameter
|
||||||
|
const analyticsQuery = useGetAdminDashboardAnalyticsQuery({ period });
|
||||||
|
|
||||||
|
// Review queues (for tables and fallback counts)
|
||||||
const queue = useGetQueueQuery();
|
const queue = useGetQueueQuery();
|
||||||
const mine = useGetAssignedToMeQuery();
|
const mine = useGetAssignedToMeQuery();
|
||||||
const table = useServerTable();
|
const table = useServerTable();
|
||||||
|
|
||||||
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
// Department queues
|
||||||
|
const registrations = useListSeafarerRegistrationsQuery({
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
const seamanBooks = useListSeafarerDocumentsQuery({
|
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||||
kind: 'SEAMAN_BOOK',
|
kind: 'SEAMAN_BOOK',
|
||||||
status: 'PAYMENT_PENDING',
|
status: 'PAYMENT_PENDING',
|
||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
|
const btcDocuments = useListSeafarerDocumentsQuery({
|
||||||
|
kind: 'BTC_BASIC_TRAINING',
|
||||||
|
status: 'PAYMENT_PENDING',
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
const vesselsQuery = useGetVesselsQuery({ search: '' });
|
||||||
|
|
||||||
if (queue.isLoading || mine.isLoading) {
|
const analytics = analyticsQuery.data;
|
||||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Real data arrays from queries
|
||||||
const unclaimed = queue.data?.items ?? [];
|
const unclaimed = queue.data?.items ?? [];
|
||||||
const inProgress = mine.data?.items ?? [];
|
const inProgress = mine.data?.items ?? [];
|
||||||
const all = [...unclaimed, ...inProgress];
|
const allInFlight = [...unclaimed, ...inProgress];
|
||||||
|
|
||||||
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
// Fallback counts if analytics is loading
|
||||||
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
const fallbackNeedsApplicant = allInFlight.filter(
|
||||||
|
(a) => a.status === 'RESUBMIT_REQUIRED',
|
||||||
|
).length;
|
||||||
|
const fallbackAwaitingPayment = allInFlight.filter(
|
||||||
|
(a) => a.status === 'PAYMENT_PENDING',
|
||||||
|
).length;
|
||||||
|
|
||||||
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
|
||||||
const byAge = [...unclaimed].sort((a, b) =>
|
|
||||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
|
||||||
);
|
|
||||||
const paged = table.paginate(byAge.slice(0, 8));
|
|
||||||
|
|
||||||
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
|
||||||
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||||
q.isError ? undefined : q.data?.total;
|
q.isError ? undefined : q.data?.total;
|
||||||
|
|
||||||
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||||
|
|
||||||
return (
|
// KPIs merging backend analytics with client queries for instant accuracy
|
||||||
<Stack gap="lg">
|
const unclaimedCount = analytics?.kpis.unclaimedQueue ?? unclaimed.length;
|
||||||
<PageHeader
|
const assignedToMeCount = analytics?.kpis.assignedToMe ?? inProgress.length;
|
||||||
title="Dashboard"
|
const inProgressTotalCount =
|
||||||
subtitle="Work waiting across the Authority's review queues."
|
analytics?.kpis.inProgressTotal ?? inProgress.length;
|
||||||
noMargin
|
const needsApplicantCount =
|
||||||
/>
|
analytics?.kpis.needsApplicant ?? fallbackNeedsApplicant;
|
||||||
|
const awaitingPaymentCount =
|
||||||
|
analytics?.kpis.awaitingPayment ?? fallbackAwaitingPayment;
|
||||||
|
const overdueSlaCount = analytics?.kpis.overdueSla ?? 0;
|
||||||
|
const activeLicensesCount = analytics?.kpis.activeLicenses ?? 0;
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
// Department counts
|
||||||
|
const seafarerRegCount =
|
||||||
|
analytics?.departmentSummary.seafarerRegistrations.submitted ??
|
||||||
|
countOf(registrations);
|
||||||
|
const seamanBookCount =
|
||||||
|
analytics?.departmentSummary.seamanBooks.pending ?? countOf(seamanBooks);
|
||||||
|
const btcCount =
|
||||||
|
analytics?.departmentSummary.btc.pending ?? countOf(btcDocuments);
|
||||||
|
const vesselCount =
|
||||||
|
analytics?.departmentSummary.vessels.total ?? countOf(vesselsQuery);
|
||||||
|
|
||||||
|
// Revenue display
|
||||||
|
const totalRevenue = analytics?.kpis.revenue.totalCollected ?? 0;
|
||||||
|
const formattedRevenue =
|
||||||
|
totalRevenue > 0
|
||||||
|
? `${totalRevenue.toLocaleString('en-US')} ETB`
|
||||||
|
: 'Active';
|
||||||
|
|
||||||
|
// Oldest first sorting for unclaimed worklist
|
||||||
|
const byAge = useMemo(() => {
|
||||||
|
return [...unclaimed].sort((a, b) =>
|
||||||
|
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||||
|
);
|
||||||
|
}, [unclaimed]);
|
||||||
|
|
||||||
|
const pagedUnclaimed = table.paginate(byAge.slice(0, 8));
|
||||||
|
|
||||||
|
// Urgent SLA applications list
|
||||||
|
const urgentApps = analytics?.urgentApplications ?? [];
|
||||||
|
|
||||||
|
const handleRefreshAll = () => {
|
||||||
|
analyticsQuery.refetch();
|
||||||
|
queue.refetch();
|
||||||
|
mine.refetch();
|
||||||
|
registrations.refetch();
|
||||||
|
seamanBooks.refetch();
|
||||||
|
notify.success('Dashboard metrics refreshed.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const periodLabelMap: Record<string, string> = {
|
||||||
|
'7d': 'Last 7 Days',
|
||||||
|
'30d': 'Last 30 Days',
|
||||||
|
'6m': 'Last 6 Months',
|
||||||
|
'1y': 'Last 1 Year',
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSV Report Generator
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push('ETHIOPIAN MARITIME AUTHORITY - OPERATIONS DASHBOARD REPORT');
|
||||||
|
lines.push(`Generated At,${new Date().toISOString()}`);
|
||||||
|
lines.push(`Time Window,${periodLabelMap[period] ?? period}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Section 1: Executive KPIs
|
||||||
|
lines.push('--- EXECUTIVE KPIS ---');
|
||||||
|
lines.push('Metric,Value');
|
||||||
|
lines.push(`Awaiting Claim (Unclaimed Pool),${unclaimedCount}`);
|
||||||
|
lines.push(`Assigned to Me,${assignedToMeCount}`);
|
||||||
|
lines.push(`In Review (All Staff),${inProgressTotalCount}`);
|
||||||
|
lines.push(`Action Required (Applicant Edits),${needsApplicantCount}`);
|
||||||
|
lines.push(`Awaiting Payment,${awaitingPaymentCount}`);
|
||||||
|
lines.push(`SLA Overdue,${overdueSlaCount}`);
|
||||||
|
lines.push(`SLA Compliance Rate,${analytics?.kpis.slaComplianceRate ?? 100}%`);
|
||||||
|
lines.push(`Active Registered Licences,${activeLicensesCount}`);
|
||||||
|
lines.push(`Total Settled Revenue (ETB),${totalRevenue}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Section 2: Department Summary
|
||||||
|
lines.push('--- DEPARTMENT WORKLOADS ---');
|
||||||
|
lines.push('Department / Service,Pending Count,Total Count');
|
||||||
|
lines.push(
|
||||||
|
`Seafarer Registrations,${seafarerRegCount ?? 0},${analytics?.departmentSummary.seafarerRegistrations.total ?? 0}`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`Seaman Books,${seamanBookCount ?? 0},${analytics?.departmentSummary.seamanBooks.total ?? 0}`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`Basic Training (BTC),${btcCount ?? 0},${analytics?.departmentSummary.btc.total ?? 0}`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`Vessel Registry,${analytics?.departmentSummary.vessels.pending ?? 0},${vesselCount ?? 0}`,
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Section 3: Time Series
|
||||||
|
if (analytics?.monthlyTrend && analytics.monthlyTrend.length > 0) {
|
||||||
|
lines.push('--- INTAKE & DECISIONS TREND ---');
|
||||||
|
lines.push('Period,Submitted,Approved / Issued,Rejected,Revenue (ETB)');
|
||||||
|
for (const row of analytics.monthlyTrend) {
|
||||||
|
lines.push(
|
||||||
|
`"${row.month} ${row.year}",${row.submitted},${row.approved},${row.rejected},${row.revenue}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 4: Category Breakdown
|
||||||
|
if (analytics?.categoryBreakdown && analytics.categoryBreakdown.length > 0) {
|
||||||
|
lines.push('--- CATEGORY BREAKDOWN ---');
|
||||||
|
lines.push('Category,Applications Count,Percentage');
|
||||||
|
for (const cat of analytics.categoryBreakdown) {
|
||||||
|
lines.push(`"${cat.category}",${cat.count},${cat.percentage}%`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 5: Urgent Applications
|
||||||
|
if (urgentApps.length > 0) {
|
||||||
|
lines.push('--- SLA PRIORITY WORKLIST ---');
|
||||||
|
lines.push(
|
||||||
|
'Application Number,Company / Applicant,License Type,Category,Status,Hours Remaining / Overdue',
|
||||||
|
);
|
||||||
|
for (const app of urgentApps) {
|
||||||
|
lines.push(
|
||||||
|
`"${app.applicationNumber}","${app.companyName ?? 'Applicant'}","${app.licenseTypeName}","${app.category}","${app.isOverdue ? 'OVERDUE' : 'AT RISK'}","${app.hoursLeft}h"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvContent = lines.join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute(
|
||||||
|
'download',
|
||||||
|
`EMA-Operations-Dashboard-${period}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||||
|
);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
notify.success('Executive dashboard CSV report generated and downloaded.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInitialLoading = queue.isLoading && analyticsQuery.isLoading;
|
||||||
|
if (isInitialLoading) {
|
||||||
|
return <PageLoader label="Loading Backoffice Operations Dashboard…" height={450} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
{/* Header & Quick Action Jump Bar */}
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||||
|
<PageHeader
|
||||||
|
title="Operations Dashboard"
|
||||||
|
subtitle="Central administrative hub for review queues, turnaround SLAs, and departmental operations."
|
||||||
|
noMargin
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
{/* Time Window Selector */}
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
value={period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
data={[
|
||||||
|
{ label: '7 Days', value: '7d' },
|
||||||
|
{ label: '30 Days', value: '30d' },
|
||||||
|
{ label: '6 Months', value: '6m' },
|
||||||
|
{ label: '1 Year', value: '1y' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip label="Export full dashboard summary as CSV">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconDownload size={15} />}
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Refresh all live data">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
loading={analyticsQuery.isFetching || queue.isFetching}
|
||||||
|
>
|
||||||
|
<IconRefresh size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconInbox size={16} />}
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
>
|
||||||
|
Licence Queue
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconUserCheck size={16} />}
|
||||||
|
onClick={() => navigate('/seafarer-registrations')}
|
||||||
|
>
|
||||||
|
Seafarers
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="cyan"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconShip size={16} />}
|
||||||
|
onClick={() => navigate('/vessel-registration-queue')}
|
||||||
|
>
|
||||||
|
Vessels
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* SLA Alert Banner if work has breached deadline */}
|
||||||
|
{overdueSlaCount > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<IconAlertTriangle size={20} />}
|
||||||
|
title="Attention: SLA Turnaround Threshold Exceeded"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap">
|
||||||
|
<Text size="sm">
|
||||||
|
<Text span fw={700}>
|
||||||
|
{overdueSlaCount} {overdueSlaCount === 1 ? 'application' : 'applications'}
|
||||||
|
</Text>{' '}
|
||||||
|
have exceeded the official service level agreement timeframe and require immediate officer action.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
>
|
||||||
|
Review Overdue Work
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary Executive KPI Tiles (Row of 6) */}
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 6 }} spacing="md">
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Awaiting claim"
|
label="Awaiting Claim"
|
||||||
value={unclaimed.length}
|
value={unclaimedCount}
|
||||||
hint="Licence applications nobody has picked up"
|
hint="Unclaimed pool in queue"
|
||||||
icon={IconInbox}
|
icon={IconInbox}
|
||||||
tone="info"
|
tone="info"
|
||||||
onClick={() => navigate('/licence-review')}
|
onClick={() => navigate('/licence-review')}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Assigned to me"
|
label="Assigned to Me"
|
||||||
value={inProgress.length}
|
value={assignedToMeCount}
|
||||||
hint="Your open licence reviews"
|
hint="Your claimed active reviews"
|
||||||
icon={IconFileText}
|
icon={IconFileText}
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
onClick={() => navigate('/licence-review')}
|
onClick={() => navigate('/licence-review')}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Needs applicant action"
|
label="In Review (All)"
|
||||||
value={needsApplicant}
|
value={inProgressTotalCount}
|
||||||
hint="Returned for corrections"
|
hint="Under review across all staff"
|
||||||
|
icon={IconUsers}
|
||||||
|
tone="info"
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Action Required"
|
||||||
|
value={needsApplicantCount}
|
||||||
|
hint="Returned for applicant edits"
|
||||||
icon={IconAlertTriangle}
|
icon={IconAlertTriangle}
|
||||||
tone="pending"
|
tone="pending"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Awaiting payment"
|
label="Awaiting Payment"
|
||||||
value={awaitingPayment}
|
value={awaitingPaymentCount}
|
||||||
hint="Approved, fee not yet settled"
|
hint="Approved, fee unsettled"
|
||||||
icon={IconCreditCard}
|
icon={IconCreditCard}
|
||||||
tone="warning"
|
tone="warning"
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
|
||||||
it instead of stretching each tile to half the page. */}
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Seafarer registrations"
|
label="SLA Overdue"
|
||||||
value={show(countOf(registrations))}
|
value={overdueSlaCount}
|
||||||
hint="Submitted, awaiting review"
|
hint={overdueSlaCount > 0 ? 'Urgent attention required' : 'All within SLA window'}
|
||||||
icon={IconUserCheck}
|
icon={IconClockExclamation}
|
||||||
tone="info"
|
tone={overdueSlaCount > 0 ? 'danger' : 'neutral'}
|
||||||
onClick={() => navigate('/seafarer-registrations')}
|
onClick={() => navigate('/licence-review')}
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="Seaman books"
|
|
||||||
value={show(countOf(seamanBooks))}
|
|
||||||
hint="Released, awaiting payment"
|
|
||||||
icon={IconCreditCard}
|
|
||||||
tone="pending"
|
|
||||||
onClick={() => navigate('/seaman-book-queue')}
|
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Multi-Department Operations & Cross-Functional Strip */}
|
||||||
|
<Card withBorder radius="lg" p="md">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={24} radius="sm" variant="light" color="blue">
|
||||||
|
<IconFolders size={15} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Cross-Department Workloads & Revenue
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Live pipeline counts across Maritime Authority desks
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 5 }} spacing="md" mt="sm">
|
||||||
|
{/* Dept 1: Seafarer Registrations */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||||
|
transition: 'transform 120ms ease',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate('/seafarer-registrations')}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Seafarer Regs
|
||||||
|
</Text>
|
||||||
|
<ThemeIcon size={20} radius="xl" variant="light" color="teal">
|
||||||
|
<IconUserCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Text fz={22} fw={800} c="teal">
|
||||||
|
{show(seafarerRegCount)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Submitted for review
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dept 2: Seaman Books */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate('/seaman-book-queue')}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Seaman Books
|
||||||
|
</Text>
|
||||||
|
<ThemeIcon size={20} radius="xl" variant="light" color="indigo">
|
||||||
|
<IconCertificate size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Text fz={22} fw={800} c="indigo">
|
||||||
|
{show(seamanBookCount)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Pending fee & issuance
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dept 3: Basic Training Certificates */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate('/btc-queue')}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
BTC Training
|
||||||
|
</Text>
|
||||||
|
<ThemeIcon size={20} radius="xl" variant="light" color="blue">
|
||||||
|
<IconCertificate size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Text fz={22} fw={800} c="blue">
|
||||||
|
{show(btcCount)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Certificates in progress
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dept 4: Vessel Registry */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate('/vessel-registration-queue')}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Vessel Registry
|
||||||
|
</Text>
|
||||||
|
<ThemeIcon size={20} radius="xl" variant="light" color="cyan">
|
||||||
|
<IconShip size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Text fz={22} fw={800} c="cyan">
|
||||||
|
{show(vesselCount)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Vessels registered
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dept 5: Settled Fees / Revenue */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Settled Fees
|
||||||
|
</Text>
|
||||||
|
<ThemeIcon size={20} radius="xl" variant="light" color="green">
|
||||||
|
<IconCash size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Text fz={20} fw={800} c="green" lineClamp={1}>
|
||||||
|
{formattedRevenue}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Collections to date
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Interactive Visualizations & Charts with Selected Period Label */}
|
||||||
|
<DashboardCharts
|
||||||
|
analytics={analytics}
|
||||||
|
periodLabel={periodLabelMap[period] ?? period}
|
||||||
|
fallbackStatusDistribution={[
|
||||||
|
{ name: 'Awaiting Claim', value: unclaimed.length, color: '#339af0' },
|
||||||
|
{ name: 'Assigned to Me', value: inProgress.length, color: '#4c6ef5' },
|
||||||
|
{ name: 'Action Required', value: fallbackNeedsApplicant, color: '#fab005' },
|
||||||
|
{ name: 'Awaiting Payment', value: fallbackAwaitingPayment, color: '#fd7e14' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Operational Worklists & Actionable Queues */}
|
||||||
<Grid gutter="lg">
|
<Grid gutter="lg">
|
||||||
|
{/* Main Worklist: Oldest Unclaimed Pool or SLA Critical */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<AdvancedTable<LicenseApplication>
|
<Card withBorder radius="lg" p="md" h="100%">
|
||||||
title="Awaiting claim — oldest first"
|
<Tabs
|
||||||
tableName="Awaiting claim"
|
value={activeTab}
|
||||||
columns={dashboardQueueColumns}
|
onChange={setActiveTab}
|
||||||
data={paged.rows}
|
variant="pills"
|
||||||
itemCount={paged.itemCount}
|
radius="md"
|
||||||
pageIndex={paged.pageIndex}
|
>
|
||||||
onPageChange={table.setPageIndex}
|
<Group justify="space-between" mb="md" wrap="nowrap">
|
||||||
pageSize={table.pageSize}
|
<Tabs.List>
|
||||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
<Tabs.Tab
|
||||||
refresh={queue.refetch}
|
value="unclaimed"
|
||||||
isLoading={queue.isFetching}
|
leftSection={<IconInbox size={15} />}
|
||||||
emptyText="Nothing waiting to be claimed."
|
>
|
||||||
toolbar={
|
Unclaimed Work Pool ({byAge.length})
|
||||||
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
</Tabs.Tab>
|
||||||
Open queue
|
<Tabs.Tab
|
||||||
</Anchor>
|
value="sla"
|
||||||
}
|
leftSection={<IconClock size={15} />}
|
||||||
/>
|
color={urgentApps.length > 0 ? 'red' : 'gray'}
|
||||||
|
>
|
||||||
|
SLA Priority ({urgentApps.length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
c="blue"
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
>
|
||||||
|
View Full Queue <IconChevronRight size={12} />
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tabs.Panel value="unclaimed">
|
||||||
|
<AdvancedTable<LicenseApplication>
|
||||||
|
title="Awaiting Claim — Oldest First"
|
||||||
|
tableName="Awaiting claim"
|
||||||
|
columns={dashboardQueueColumns}
|
||||||
|
data={pagedUnclaimed.rows}
|
||||||
|
itemCount={pagedUnclaimed.itemCount}
|
||||||
|
pageIndex={pagedUnclaimed.pageIndex}
|
||||||
|
onPageChange={table.setPageIndex}
|
||||||
|
pageSize={table.pageSize}
|
||||||
|
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||||
|
refresh={queue.refetch}
|
||||||
|
isLoading={queue.isFetching}
|
||||||
|
emptyText="No applications currently waiting to be claimed."
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="sla">
|
||||||
|
{urgentApps.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||||
|
All applications are safely within their service level agreement deadlines.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{urgentApps.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
withBorder
|
||||||
|
p="sm"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderColor: item.isOverdue
|
||||||
|
? 'var(--mantine-color-red-outline)'
|
||||||
|
: 'var(--mantine-color-yellow-outline)',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/licence-review/${item.id}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
size={28}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={item.isOverdue ? 'red' : 'yellow'}
|
||||||
|
>
|
||||||
|
<IconClockExclamation size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm" c="blue">
|
||||||
|
{item.applicationNumber}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{item.companyName ?? 'Applicant'} • {item.licenseTypeName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={item.isOverdue ? 'red' : 'yellow'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item.isOverdue
|
||||||
|
? `Overdue by ${Math.abs(item.hoursLeft)}h`
|
||||||
|
: `${item.hoursLeft}h remaining`}
|
||||||
|
</Badge>
|
||||||
|
<IconChevronRight size={14} color="var(--mantine-color-dimmed)" />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Side Panel: Longest Waiting & Fast Directory */}
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
<Stack gap="md" h="100%">
|
||||||
<Text fw={600} size="sm" mb="xs">
|
{/* Longest Waiting Applications Card */}
|
||||||
Longest waiting
|
<Paper withBorder radius="lg" p="lg">
|
||||||
</Text>
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" mb="md">
|
<Text fw={600} size="sm">
|
||||||
Unclaimed applications, by how long they have sat.
|
Longest Waiting Applications
|
||||||
</Text>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{byAge.slice(0, 5).map((app) => (
|
|
||||||
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
lineClamp={1}
|
|
||||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
|
||||||
>
|
|
||||||
{app.applicationNumber}
|
|
||||||
</Anchor>
|
|
||||||
<WaitingFor
|
|
||||||
since={app.submittedAt ?? app.createdAt}
|
|
||||||
slaDays={
|
|
||||||
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
{byAge.length === 0 && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Nothing waiting.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<IconClock size={16} color="var(--mantine-color-dimmed)" />
|
||||||
</Stack>
|
</Group>
|
||||||
</Paper>
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
|
Unclaimed applications sitting in queue by age.
|
||||||
|
</Text>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{byAge.slice(0, 5).map((app) => (
|
||||||
|
<Group
|
||||||
|
key={app.id}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
gap="sm"
|
||||||
|
>
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{app.applicationNumber}
|
||||||
|
</Anchor>
|
||||||
|
<WaitingFor
|
||||||
|
since={app.submittedAt ?? app.createdAt}
|
||||||
|
slaDays={
|
||||||
|
app.licenseType?.slaHours
|
||||||
|
? app.licenseType.slaHours / 24
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{byAge.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed" py="xs">
|
||||||
|
Nothing waiting to be claimed.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Quick Operations Navigation Directory */}
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ flexGrow: 1 }}>
|
||||||
|
<Text fw={600} size="sm" mb="xs">
|
||||||
|
Quick Authority Desks
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
|
Direct access to core backoffice review workspaces.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
justify="space-between"
|
||||||
|
rightSection={<IconChevronRight size={14} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconInbox size={16} color="var(--mantine-color-blue-filled)" />
|
||||||
|
<Text size="sm">Licence Review Queue</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
justify="space-between"
|
||||||
|
rightSection={<IconChevronRight size={14} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/licence-register')}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconCertificate size={16} color="var(--mantine-color-teal-filled)" />
|
||||||
|
<Text size="sm">Official Licence Register</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
justify="space-between"
|
||||||
|
rightSection={<IconChevronRight size={14} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/seafarer-registrations')}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconUserCheck size={16} color="var(--mantine-color-indigo-filled)" />
|
||||||
|
<Text size="sm">Seafarer Registrations</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
justify="space-between"
|
||||||
|
rightSection={<IconChevronRight size={14} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/seaman-book-queue')}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconCreditCard size={16} color="var(--mantine-color-orange-filled)" />
|
||||||
|
<Text size="sm">Seaman Books & BTC</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
justify="space-between"
|
||||||
|
rightSection={<IconChevronRight size={14} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/vessel-registration-queue')}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconShip size={16} color="var(--mantine-color-cyan-filled)" />
|
||||||
|
<Text size="sm">Vessel Register Queue</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
justify="space-between"
|
||||||
|
rightSection={<IconChevronRight size={14} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/exams')}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconFolders size={16} color="var(--mantine-color-grape-filled)" />
|
||||||
|
<Text size="sm">Examinations & CoC</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IconFileUpload,
|
IconFileUpload,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
|
IconPencilCheck,
|
||||||
IconUserCheck,
|
IconUserCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -19,10 +20,16 @@ import {
|
|||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
type ApplicationDetail,
|
type ApplicationDetail,
|
||||||
|
type ApplicationRemark,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
type EntryKind =
|
||||||
|
| 'status'
|
||||||
|
| 'remark'
|
||||||
|
| 'correction'
|
||||||
|
| 'upload'
|
||||||
|
| 'assignment';
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +44,7 @@ interface ActivityEntry {
|
|||||||
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||||
status: IconArrowRight,
|
status: IconArrowRight,
|
||||||
remark: IconMessage,
|
remark: IconMessage,
|
||||||
|
correction: IconPencilCheck,
|
||||||
upload: IconFileUpload,
|
upload: IconFileUpload,
|
||||||
assignment: IconUserCheck,
|
assignment: IconUserCheck,
|
||||||
};
|
};
|
||||||
@@ -50,7 +58,18 @@ const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
|||||||
* the trade-off is that it can only show what the detail payload carries, and
|
* the trade-off is that it can only show what the detail payload carries, and
|
||||||
* notifications sent to the applicant are not among them.
|
* notifications sent to the applicant are not among them.
|
||||||
*/
|
*/
|
||||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
export function ActivityRail({
|
||||||
|
detail,
|
||||||
|
remarkLabel,
|
||||||
|
}: {
|
||||||
|
detail: ApplicationDetail;
|
||||||
|
/**
|
||||||
|
* Names a remark's target for the officer. The page owns this because it
|
||||||
|
* holds the licence type config the labels come from; without it the trail
|
||||||
|
* falls back to the raw key, which for a staff remark is a uuid.
|
||||||
|
*/
|
||||||
|
remarkLabel?: (remark: ApplicationRemark) => string;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
@@ -82,18 +101,37 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const remark of detail.remarks ?? []) {
|
for (const remark of detail.remarks ?? []) {
|
||||||
|
const target = remarkLabel?.(remark) ?? remark.targetKey;
|
||||||
merged.push({
|
merged.push({
|
||||||
id: `remark-${remark.id}`,
|
id: `remark-${remark.id}`,
|
||||||
kind: 'remark',
|
kind: 'remark',
|
||||||
at: remark.createdAt,
|
at: remark.createdAt,
|
||||||
actor: t('review.activity.officer', 'Officer'),
|
actor: t('review.activity.officer', 'Officer'),
|
||||||
title: t('review.activity.remarkOn', {
|
title: t('review.activity.remarkOn', {
|
||||||
target: remark.targetKey,
|
target,
|
||||||
defaultValue: 'Correction requested on {{target}}',
|
defaultValue: 'Correction requested on {{target}}',
|
||||||
}),
|
}),
|
||||||
detail: remark.remark,
|
detail: remark.remark,
|
||||||
color: remark.resolvedAt ? 'teal' : 'orange',
|
color: remark.resolvedAt ? 'teal' : 'orange',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The correction itself, at the moment the data moved. Distinct from the
|
||||||
|
// remark above (which is the *request*) and from `resolvedAt`, which only
|
||||||
|
// records the applicant ticking the item off — the portal does that in
|
||||||
|
// bulk on resubmit, so it says nothing about what was actually edited.
|
||||||
|
if (remark.valueChangedAt) {
|
||||||
|
merged.push({
|
||||||
|
id: `correction-${remark.id}`,
|
||||||
|
kind: 'correction',
|
||||||
|
at: remark.valueChangedAt,
|
||||||
|
actor: t('review.activity.applicant', 'Applicant'),
|
||||||
|
title: t('review.activity.correctedTarget', {
|
||||||
|
target,
|
||||||
|
defaultValue: 'Corrected {{target}}',
|
||||||
|
}),
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const attachment of detail.attachments ?? []) {
|
for (const attachment of detail.attachments ?? []) {
|
||||||
@@ -118,7 +156,7 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
|||||||
return merged.sort(
|
return merged.sort(
|
||||||
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
|
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
|
||||||
);
|
);
|
||||||
}, [detail, t]);
|
}, [detail, remarkLabel, t]);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
IconDownload,
|
IconDownload,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
|
IconPencilCheck,
|
||||||
IconRotate,
|
IconRotate,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -43,6 +44,11 @@ interface DocumentsTabProps {
|
|||||||
requirements: DocumentRequirement[];
|
requirements: DocumentRequirement[];
|
||||||
/** Applicant answers used to evaluate conditional document requirements. */
|
/** Applicant answers used to evaluate conditional document requirements. */
|
||||||
formData: Record<string, Record<string, unknown>>;
|
formData: Record<string, Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* Document keys the applicant actually re-uploaded during the open
|
||||||
|
* correction round — the files worth re-opening first on a resubmission.
|
||||||
|
*/
|
||||||
|
correctedKeys?: Set<string>;
|
||||||
/** documentKey -> remark. Owned by the review page. */
|
/** documentKey -> remark. Owned by the review page. */
|
||||||
flags: Record<string, string>;
|
flags: Record<string, string>;
|
||||||
onToggleFlag: (documentKey: string) => void;
|
onToggleFlag: (documentKey: string) => void;
|
||||||
@@ -63,6 +69,7 @@ export function DocumentsTab({
|
|||||||
attachments,
|
attachments,
|
||||||
requirements,
|
requirements,
|
||||||
formData,
|
formData,
|
||||||
|
correctedKeys,
|
||||||
flags,
|
flags,
|
||||||
onToggleFlag,
|
onToggleFlag,
|
||||||
onFlagRemark,
|
onFlagRemark,
|
||||||
@@ -243,6 +250,16 @@ export function DocumentsTab({
|
|||||||
{t("review.documents.flagged", "Correction requested")}
|
{t("review.documents.flagged", "Correction requested")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{correctedKeys?.has(attachment.documentKey) && (
|
||||||
|
<Badge
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconPencilCheck size={11} />}
|
||||||
|
>
|
||||||
|
{t("review.corrected.badge", "Corrected")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{file?.originalName ??
|
{file?.originalName ??
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconMapPin,
|
||||||
|
IconPencilCheck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
@@ -34,6 +38,12 @@ interface FormDetailsTabProps {
|
|||||||
/** The licence type's form schema — the order and labels to render by. */
|
/** The licence type's form schema — the order and labels to render by. */
|
||||||
configSections: FormSectionConfig[];
|
configSections: FormSectionConfig[];
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
/**
|
||||||
|
* Section keys the applicant actually edited during the open correction
|
||||||
|
* round, so a returning application points at itself instead of making the
|
||||||
|
* officer re-read every section to find the two that moved.
|
||||||
|
*/
|
||||||
|
correctedKeys?: Set<string>;
|
||||||
/** sectionKey -> remark. Owned by the review page. */
|
/** sectionKey -> remark. Owned by the review page. */
|
||||||
flags: Record<string, { remark: string }>;
|
flags: Record<string, { remark: string }>;
|
||||||
onToggleFlag: (sectionKey: string) => void;
|
onToggleFlag: (sectionKey: string) => void;
|
||||||
@@ -63,6 +73,7 @@ export function FormDetailsTab({
|
|||||||
formData,
|
formData,
|
||||||
configSections,
|
configSections,
|
||||||
currency,
|
currency,
|
||||||
|
correctedKeys,
|
||||||
flags,
|
flags,
|
||||||
onToggleFlag,
|
onToggleFlag,
|
||||||
onFlagRemark,
|
onFlagRemark,
|
||||||
@@ -145,6 +156,16 @@ export function FormDetailsTab({
|
|||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
{section.title}
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
{correctedKeys?.has(section.key) && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPencilCheck size={10} />}
|
||||||
|
>
|
||||||
|
{t('review.corrected.badge', 'Corrected')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{missing > 0 && (
|
{missing > 0 && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t(
|
label={t(
|
||||||
|
|||||||
@@ -9,36 +9,73 @@ const REASONS = {
|
|||||||
needsFlags: "needs flags",
|
needsFlags: "needs flags",
|
||||||
needsCapital: "needs capital",
|
needsCapital: "needs capital",
|
||||||
needsInspection: "needs inspection",
|
needsInspection: "needs inspection",
|
||||||
|
inspectionFailed: "inspection failed",
|
||||||
needsDocumentReviews: "needs document reviews",
|
needsDocumentReviews: "needs document reviews",
|
||||||
inspectionNotYetDue: "not yet due",
|
inspectionNotYetDue: "not yet due",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Held by someone else, so ownership gating is what the assertions turn on. */
|
/** Held by someone else, so ownership gating is what the assertions turn on. */
|
||||||
function detailFor(licenseTypeKey: string): ApplicationDetail {
|
function detailFor(
|
||||||
|
licenseTypeKey: string,
|
||||||
|
certificateCategory?: string | null,
|
||||||
|
): ApplicationDetail {
|
||||||
return {
|
return {
|
||||||
application: {
|
application: {
|
||||||
id: "app-1",
|
id: "app-1",
|
||||||
status: "SUBMITTED",
|
status: "SUBMITTED",
|
||||||
assignedOfficerId: "another-officer",
|
assignedOfficerId: "another-officer",
|
||||||
licenseType: { key: licenseTypeKey },
|
licenseType:
|
||||||
|
certificateCategory === undefined
|
||||||
|
? { key: licenseTypeKey }
|
||||||
|
: { key: licenseTypeKey, certificateCategory },
|
||||||
},
|
},
|
||||||
availableEvents: ["claim", "assign", "request-adjustment"],
|
availableEvents: ["claim", "assign", "request-adjustment"],
|
||||||
} as unknown as ApplicationDetail;
|
} as unknown as ApplicationDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(licenseTypeKey: string) {
|
function resolve(licenseTypeKey: string, certificateCategory?: string | null) {
|
||||||
return resolveActions({
|
return resolveActions({
|
||||||
detail: detailFor(licenseTypeKey),
|
detail: detailFor(licenseTypeKey, certificateCategory),
|
||||||
currentUserId: "me",
|
currentUserId: "me",
|
||||||
can: () => true,
|
can: () => true,
|
||||||
reasons: REASONS,
|
reasons: REASONS,
|
||||||
flaggedCount: 1,
|
flaggedCount: 1,
|
||||||
hasPendingInspection: false,
|
hasPendingInspection: false,
|
||||||
inspectionNotYetDue: false,
|
inspectionNotYetDue: false,
|
||||||
|
latestInspectionPassed: null,
|
||||||
allDocumentsAccepted: true,
|
allDocumentsAccepted: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An inspected licence sitting on INSPECTION_COMPLETED, which is the only
|
||||||
|
* status Final Approve fires from — so the inspection *result* is all that
|
||||||
|
* separates an approvable file from an unapprovable one.
|
||||||
|
*/
|
||||||
|
function inspected(latestInspectionPassed: boolean | null) {
|
||||||
|
const detail = {
|
||||||
|
application: {
|
||||||
|
id: "app-1",
|
||||||
|
status: "INSPECTION_COMPLETED",
|
||||||
|
assignedOfficerId: "me",
|
||||||
|
licenseType: { key: "VESSEL_REGISTRATION", inspectionRequired: true },
|
||||||
|
},
|
||||||
|
availableEvents: ["final-approve", "request-adjustment", "reject"],
|
||||||
|
} as unknown as ApplicationDetail;
|
||||||
|
|
||||||
|
return resolveActions({
|
||||||
|
detail,
|
||||||
|
currentUserId: "me",
|
||||||
|
can: () => true,
|
||||||
|
reasons: REASONS,
|
||||||
|
flaggedCount: 1,
|
||||||
|
hasPendingInspection: false,
|
||||||
|
inspectionNotYetDue: false,
|
||||||
|
latestInspectionPassed,
|
||||||
|
allDocumentsAccepted: true,
|
||||||
|
}).find((a) => a.id === "final-approve");
|
||||||
|
}
|
||||||
|
|
||||||
describe("resolveActions — seafarer certificates skip the queue", () => {
|
describe("resolveActions — seafarer certificates skip the queue", () => {
|
||||||
it("drops claim and assign for a CoC", () => {
|
it("drops claim and assign for a CoC", () => {
|
||||||
const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id);
|
const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id);
|
||||||
@@ -61,4 +98,133 @@ describe("resolveActions — seafarer certificates skip the queue", () => {
|
|||||||
expect(adjust?.enabled).toBe(false);
|
expect(adjust?.enabled).toBe(false);
|
||||||
expect(adjust?.disabledReason).toBe(REASONS.notAssigned);
|
expect(adjust?.disabledReason).toBe(REASONS.notAssigned);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Behaviour tab's `certificateCategory` decides, not the key: a type an
|
||||||
|
// administrator configured as a CoC skips the queue whatever it is called,
|
||||||
|
// and a type they cleared the category on is queued like any licence.
|
||||||
|
it("skips the queue for a type configured as a CoC under any key", () => {
|
||||||
|
const ids = resolve("MASTER_MARINER", "COC").map((a) => a.id);
|
||||||
|
expect(ids).not.toContain("claim");
|
||||||
|
expect(ids).not.toContain("assign");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues a certificate-looking key whose category was cleared", () => {
|
||||||
|
const actions = resolve("COC_MASTER", null);
|
||||||
|
expect(actions.map((a) => a.id)).toContain("claim");
|
||||||
|
const adjust = actions.find((a) => a.id === "request-adjustment");
|
||||||
|
expect(adjust?.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps endorsements in the queue", () => {
|
||||||
|
expect(resolve("ENDORSEMENT_SEAFARER", "ENDORSEMENT").map((a) => a.id)).toContain(
|
||||||
|
"claim",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A completed inspection is not a passed inspection.
|
||||||
|
*
|
||||||
|
* The status alone used to unlock approval, so a vessel that failed its visit
|
||||||
|
* before INSPECTION_FAILED existed — the row stayed on INSPECTION_COMPLETED —
|
||||||
|
* offered a live Approve button the server then refused with
|
||||||
|
* `inspection_not_passed`.
|
||||||
|
*/
|
||||||
|
describe("resolveActions — approval waits on a passed inspection", () => {
|
||||||
|
it("disables final approve when the latest inspection failed", () => {
|
||||||
|
const approve = inspected(false);
|
||||||
|
expect(approve?.enabled).toBe(false);
|
||||||
|
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables final approve when no inspection was conducted", () => {
|
||||||
|
const approve = inspected(null);
|
||||||
|
expect(approve?.enabled).toBe(false);
|
||||||
|
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables final approve once an inspection passed", () => {
|
||||||
|
expect(inspected(true)?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vessel registration is gated on the visit by its type, not only by the
|
||||||
|
* admin-editable `inspectionRequired` flag — turning the flag off must not
|
||||||
|
* let a vessel be approved, or its certificate issued, on paperwork alone.
|
||||||
|
*/
|
||||||
|
describe("resolveActions — vessel registration needs a passed inspection", () => {
|
||||||
|
function vessel(
|
||||||
|
status: string,
|
||||||
|
events: string[],
|
||||||
|
latestInspectionPassed: boolean | null,
|
||||||
|
licenseTypeKey = "VESSEL_REGISTRATION",
|
||||||
|
) {
|
||||||
|
const detail = {
|
||||||
|
application: {
|
||||||
|
id: "app-1",
|
||||||
|
status,
|
||||||
|
assignedOfficerId: "me",
|
||||||
|
licenseType: { key: licenseTypeKey, inspectionRequired: false },
|
||||||
|
},
|
||||||
|
availableEvents: events,
|
||||||
|
} as unknown as ApplicationDetail;
|
||||||
|
return resolveActions({
|
||||||
|
detail,
|
||||||
|
currentUserId: "me",
|
||||||
|
can: () => true,
|
||||||
|
reasons: REASONS,
|
||||||
|
flaggedCount: 1,
|
||||||
|
hasPendingInspection: false,
|
||||||
|
inspectionNotYetDue: false,
|
||||||
|
latestInspectionPassed,
|
||||||
|
allDocumentsAccepted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("disables final approve with no passed inspection, flag or not", () => {
|
||||||
|
const approve = vessel("UNDER_EVALUATION", ["final-approve"], null).find(
|
||||||
|
(a) => a.id === "final-approve",
|
||||||
|
);
|
||||||
|
expect(approve?.enabled).toBe(false);
|
||||||
|
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables final approve after a failed inspection", () => {
|
||||||
|
const approve = vessel("UNDER_EVALUATION", ["final-approve"], false).find(
|
||||||
|
(a) => a.id === "final-approve",
|
||||||
|
);
|
||||||
|
expect(approve?.enabled).toBe(false);
|
||||||
|
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables final approve once the inspection passed", () => {
|
||||||
|
const approve = vessel("UNDER_EVALUATION", ["final-approve"], true).find(
|
||||||
|
(a) => a.id === "final-approve",
|
||||||
|
);
|
||||||
|
expect(approve?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds the certificate until the inspection passed", () => {
|
||||||
|
const issue = vessel("SCHEDULED", ["issue-certificate"], null).find(
|
||||||
|
(a) => a.id === "issue-certificate",
|
||||||
|
);
|
||||||
|
expect(issue?.enabled).toBe(false);
|
||||||
|
expect(issue?.disabledReason).toBe(REASONS.needsInspection);
|
||||||
|
expect(
|
||||||
|
vessel("SCHEDULED", ["issue-certificate"], true).find(
|
||||||
|
(a) => a.id === "issue-certificate",
|
||||||
|
)?.enabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a non-inspected type alone", () => {
|
||||||
|
const approve = vessel(
|
||||||
|
"UNDER_EVALUATION",
|
||||||
|
["final-approve"],
|
||||||
|
null,
|
||||||
|
"SEAFARER_REGISTRATION",
|
||||||
|
).find((a) => a.id === "final-approve");
|
||||||
|
expect(approve?.enabled).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
||||||
import { isSeafarerCertificate } from './license-types';
|
import {
|
||||||
|
isSeafarerCertificate,
|
||||||
|
requiresPassedInspection,
|
||||||
|
} from './license-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where an action is rendered. One tier per action, decided here rather than
|
* Where an action is rendered. One tier per action, decided here rather than
|
||||||
@@ -347,6 +350,7 @@ export interface ResolveContext {
|
|||||||
needsFlags: string;
|
needsFlags: string;
|
||||||
needsCapital: string;
|
needsCapital: string;
|
||||||
needsInspection: string;
|
needsInspection: string;
|
||||||
|
inspectionFailed: string;
|
||||||
needsDocumentReviews: string;
|
needsDocumentReviews: string;
|
||||||
inspectionNotYetDue: string;
|
inspectionNotYetDue: string;
|
||||||
};
|
};
|
||||||
@@ -360,6 +364,18 @@ export interface ResolveContext {
|
|||||||
* button waits (the server refuses early results the same way).
|
* button waits (the server refuses early results the same way).
|
||||||
*/
|
*/
|
||||||
inspectionNotYetDue: boolean;
|
inspectionNotYetDue: boolean;
|
||||||
|
/**
|
||||||
|
* Outcome of the most recent conducted visit: `true` passed, `false` failed,
|
||||||
|
* `null` none conducted yet.
|
||||||
|
*
|
||||||
|
* A completed inspection is not a passed one. Approval used to turn on the
|
||||||
|
* status alone, so an application sitting at INSPECTION_COMPLETED with a
|
||||||
|
* FAILED result behind it — what a failure produced before INSPECTION_FAILED
|
||||||
|
* existed — offered a live Approve button that the server then refused with
|
||||||
|
* `inspection_not_passed`. This is the same gate the server applies, so the
|
||||||
|
* button is dead here instead of after the click.
|
||||||
|
*/
|
||||||
|
latestInspectionPassed: boolean | null;
|
||||||
/**
|
/**
|
||||||
* False while any uploaded document is still unjudged or rejected. Approving
|
* False while any uploaded document is still unjudged or rejected. Approving
|
||||||
* is a statement that every document was checked, so the button stays dead
|
* is a statement that every document was checked, so the button stays dead
|
||||||
@@ -416,7 +432,11 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
|||||||
// A seafarer's certificate is reviewed by whoever opens it. There is no
|
// A seafarer's certificate is reviewed by whoever opens it. There is no
|
||||||
// queue to claim it from and no reviewer to assign, so the two actions that
|
// queue to claim it from and no reviewer to assign, so the two actions that
|
||||||
// move it through one are dropped and ownership stops gating the decisions.
|
// move it through one are dropped and ownership stops gating the decisions.
|
||||||
const skipsAssignment = isSeafarerCertificate(app.licenseType?.key);
|
const skipsAssignment = isSeafarerCertificate(app.licenseType);
|
||||||
|
// A vessel registration is decided on the visit, not the file: nothing may
|
||||||
|
// be approved or issued until the latest conducted inspection PASSED, and
|
||||||
|
// that holds even if the type's `inspectionRequired` flag has been edited.
|
||||||
|
const mustPassInspection = requiresPassedInspection(app.licenseType?.key);
|
||||||
|
|
||||||
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
||||||
(action) => {
|
(action) => {
|
||||||
@@ -513,6 +533,35 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
|||||||
) {
|
) {
|
||||||
return disabled(reasons.needsInspection);
|
return disabled(reasons.needsInspection);
|
||||||
}
|
}
|
||||||
|
// Mirrors the server's `inspection_not_passed`: the status says a
|
||||||
|
// visit happened, the result says whether it may be approved.
|
||||||
|
if (
|
||||||
|
(app.licenseType?.inspectionRequired || mustPassInspection) &&
|
||||||
|
ctx.latestInspectionPassed !== true
|
||||||
|
) {
|
||||||
|
return disabled(
|
||||||
|
ctx.latestInspectionPassed === false
|
||||||
|
? reasons.inspectionFailed
|
||||||
|
: reasons.needsInspection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The certificate is the other thing a passed visit unlocks. Approval
|
||||||
|
// is already gated above, so this only bites on a file that reached
|
||||||
|
// issuance some other way — but a vessel certificate with no passed
|
||||||
|
// inspection behind it is exactly the document this rule exists to
|
||||||
|
// prevent, so it is checked at the point of issue too.
|
||||||
|
if (
|
||||||
|
(action.id === 'schedule-issuance' || action.id === 'issue-certificate') &&
|
||||||
|
mustPassInspection &&
|
||||||
|
ctx.latestInspectionPassed !== true
|
||||||
|
) {
|
||||||
|
return disabled(
|
||||||
|
ctx.latestInspectionPassed === false
|
||||||
|
? reasons.inspectionFailed
|
||||||
|
: reasons.needsInspection,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...action, enabled: true };
|
return { ...action, enabled: true };
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
IconUsers,
|
IconUsers,
|
||||||
type Icon,
|
type Icon,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { LicenseApplication, LicenseType } from '@ema-platform/api';
|
import type {
|
||||||
|
CertificateCategory,
|
||||||
|
LicenseApplication,
|
||||||
|
LicenseType,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presentation-only metadata per licence type.
|
* Presentation-only metadata per licence type.
|
||||||
@@ -152,12 +156,33 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
|||||||
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
|
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
|
||||||
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
|
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
|
||||||
|
|
||||||
|
/** The configured categories that mark a type as a seafarer's own certificate. */
|
||||||
|
const SEAFARER_CERTIFICATE_CATEGORIES: ReadonlyArray<CertificateCategory> = [
|
||||||
|
'COC',
|
||||||
|
'COP',
|
||||||
|
'GOC',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CoC, CoP and the endorsement/GOC family: a seafarer's own certificate rather
|
* CoC, CoP and the GOC family: a seafarer's own certificate rather than an
|
||||||
* than an organisation's licence. They skip the claim/assign queue — see
|
* organisation's licence. They skip the claim/assign queue — see
|
||||||
* `resolveActions`.
|
* `resolveActions`.
|
||||||
|
*
|
||||||
|
* Decided by the `certificateCategory` an administrator sets on the
|
||||||
|
* certificate-requirements Behaviour tab, so a type configured as a CoC in the
|
||||||
|
* backoffice is treated as one whatever its key. The key prefixes are only a
|
||||||
|
* fallback for a payload that carries no category field at all (an older
|
||||||
|
* server, or a bare key).
|
||||||
*/
|
*/
|
||||||
export function isSeafarerCertificate(key: string | undefined): boolean {
|
export function isSeafarerCertificate(
|
||||||
|
licenseType: Pick<LicenseType, 'key' | 'certificateCategory'> | string | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!licenseType) return false;
|
||||||
|
if (typeof licenseType !== 'string' && licenseType.certificateCategory !== undefined) {
|
||||||
|
const category = licenseType.certificateCategory;
|
||||||
|
return category !== null && SEAFARER_CERTIFICATE_CATEGORIES.includes(category);
|
||||||
|
}
|
||||||
|
const key = typeof licenseType === 'string' ? licenseType : licenseType.key;
|
||||||
if (!key) return false;
|
if (!key) return false;
|
||||||
return (
|
return (
|
||||||
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
|
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
|
||||||
@@ -166,6 +191,21 @@ export function isSeafarerCertificate(key: string | undefined): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types that may never be approved, nor have a certificate issued, without a
|
||||||
|
* conducted inspection that PASSED (US-VES-007 for vessel registration).
|
||||||
|
*
|
||||||
|
* Keyed on the type rather than `inspectionRequired` alone: that flag is an
|
||||||
|
* admin-editable column, and flipping it off must not quietly let a vessel be
|
||||||
|
* registered on paperwork alone. `resolveActions` applies this on top of the
|
||||||
|
* flag, never instead of it.
|
||||||
|
*/
|
||||||
|
const PASSED_INSPECTION_REQUIRED_KEYS = new Set(['VESSEL_REGISTRATION']);
|
||||||
|
|
||||||
|
export function requiresPassedInspection(key: string | undefined): boolean {
|
||||||
|
return Boolean(key && PASSED_INSPECTION_REQUIRED_KEYS.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||||
export function presentationFor(key: string | undefined): LicenseTypePresentation {
|
export function presentationFor(key: string | undefined): LicenseTypePresentation {
|
||||||
if (key && PRESENTATION[key]) return PRESENTATION[key];
|
if (key && PRESENTATION[key]) return PRESENTATION[key];
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
AmharicDatePicker,
|
AmharicDatePicker,
|
||||||
type AdvancedColumn,
|
type AdvancedColumn,
|
||||||
} from "@ema-platform/ui";
|
} from "@ema-platform/ui";
|
||||||
|
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||||
import {
|
import {
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
SAVED_VIEWS,
|
SAVED_VIEWS,
|
||||||
@@ -133,6 +134,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
|||||||
return type.inspectionRequired;
|
return type.inspectionRequired;
|
||||||
}
|
}
|
||||||
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||||
|
if (status === "SCHEDULED") return Boolean(type.requiresIssuanceScheduling);
|
||||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
useRescheduleInspectionMutation,
|
useRescheduleInspectionMutation,
|
||||||
useGetCertificateUrlForOfficerMutation,
|
useGetCertificateUrlForOfficerMutation,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
|
type ApplicationRemark,
|
||||||
type RemarkTargetType,
|
type RemarkTargetType,
|
||||||
type StaffEvidenceRequirement,
|
type StaffEvidenceRequirement,
|
||||||
} from "@ema-platform/api";
|
} from "@ema-platform/api";
|
||||||
@@ -92,6 +93,7 @@ import {
|
|||||||
type DecisionSubmission,
|
type DecisionSubmission,
|
||||||
} from "../../components/DecisionConfirmModal";
|
} from "../../components/DecisionConfirmModal";
|
||||||
import { ActivityRail } from "../../components/ActivityRail";
|
import { ActivityRail } from "../../components/ActivityRail";
|
||||||
|
import { CorrectedItemsPanel } from "../../components/CorrectedItemsPanel";
|
||||||
import { DocumentsTab } from "../../components/DocumentsTab";
|
import { DocumentsTab } from "../../components/DocumentsTab";
|
||||||
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
||||||
import { ApplicantCard } from "../../components/ApplicantCard";
|
import { ApplicantCard } from "../../components/ApplicantCard";
|
||||||
@@ -102,6 +104,7 @@ import { reviewStaffColumns } from "./columns";
|
|||||||
import {
|
import {
|
||||||
evaluateEligibility,
|
evaluateEligibility,
|
||||||
presentationFor,
|
presentationFor,
|
||||||
|
requiresPassedInspection,
|
||||||
} from "../../config/license-types";
|
} from "../../config/license-types";
|
||||||
import {
|
import {
|
||||||
resolveActions,
|
resolveActions,
|
||||||
@@ -331,6 +334,40 @@ export function LicenseReviewPage() {
|
|||||||
) < pendingInspection.scheduledDate.slice(0, 10),
|
) < pendingInspection.scheduledDate.slice(0, 10),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The most recent visit that actually happened. `findForApplication` orders
|
||||||
|
// newest first, so the first COMPLETED row is the one that decides approval —
|
||||||
|
// a re-inspection that passed supersedes the failure before it.
|
||||||
|
const latestConductedInspection = inspections.find(
|
||||||
|
(i) => i.status === 'COMPLETED',
|
||||||
|
);
|
||||||
|
const latestInspectionPassed = latestConductedInspection
|
||||||
|
? latestConductedInspection.result === 'PASSED'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// A vessel registration's visit is pass/fail per area, nothing in between:
|
||||||
|
// there is no "fix and come back" state on a vessel, so the result form
|
||||||
|
// offers only those two, and the visit can only be recorded as PASSED when
|
||||||
|
// every area passed (US-VES-007).
|
||||||
|
const strictInspection = requiresPassedInspection(
|
||||||
|
loadedApp?.licenseType?.key,
|
||||||
|
);
|
||||||
|
const checklistOptions = [
|
||||||
|
{ value: "PASS", label: t("review.checkPass", "Pass") },
|
||||||
|
...(strictInspection
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
value: "NEEDS_CORRECTION",
|
||||||
|
label: t("review.checkFix", "Fix"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ value: "FAIL", label: t("review.checkFail", "Fail") },
|
||||||
|
];
|
||||||
|
const allChecklistPassed = INSPECTION_CHECKLIST_ITEMS.every(
|
||||||
|
(item) => (checklist[item.key] ?? "PASS") === "PASS",
|
||||||
|
);
|
||||||
|
const passBlocked = strictInspection && !allChecklistPassed;
|
||||||
|
|
||||||
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||||
useGetAttachmentsQuery(
|
useGetAttachmentsQuery(
|
||||||
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
|
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
|
||||||
@@ -384,6 +421,100 @@ export function LicenseReviewPage() {
|
|||||||
[flags, data?.staff, t, localized, roleNameByKey],
|
[flags, data?.staff, t, localized, roleNameByKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the applicant actually changed when they sent the file back.
|
||||||
|
*
|
||||||
|
* A resubmission used to arrive as a bare "round N" badge, which told the
|
||||||
|
* officer that something had been corrected but not what — so re-review meant
|
||||||
|
* re-reading the whole application. `valueChangedAt` is stamped by the edit
|
||||||
|
* itself, so these are the items whose data really moved; the ones the
|
||||||
|
* applicant ticked off without touching are listed separately, because they
|
||||||
|
* are the likeliest reason the file comes back a third time.
|
||||||
|
*
|
||||||
|
* Scoped to the current round: `requestAdjustment` increments the counter and
|
||||||
|
* `resubmit` does not, so the open round is the one just answered.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A remark target as the officer reads it: section title, document name, or
|
||||||
|
* the staff member's role and name. The wire carries only the key — and for
|
||||||
|
* a staff remark that key is an ApplicationStaff uuid — so every place that
|
||||||
|
* shows a remark (the corrections panel, the activity rail) goes through this.
|
||||||
|
*/
|
||||||
|
const remarkLabel = useMemo(() => {
|
||||||
|
const sectionTitles = new Map(
|
||||||
|
(requirements?.licenseType.formSchema.sections ?? []).map((section) => [
|
||||||
|
section.key,
|
||||||
|
localized(section.title),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const documentNames = new Map(
|
||||||
|
(requirements?.documentRequirements ?? []).map((requirement) => [
|
||||||
|
requirement.key,
|
||||||
|
localized(requirement.name),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (remark: ApplicationRemark): string => {
|
||||||
|
if (remark.targetType === "FORM_SECTION") {
|
||||||
|
return sectionTitles.get(remark.targetKey) || humaniseKey(remark.targetKey);
|
||||||
|
}
|
||||||
|
if (remark.targetType === "DOCUMENT") {
|
||||||
|
return documentNames.get(remark.targetKey) || humaniseKey(remark.targetKey);
|
||||||
|
}
|
||||||
|
const member = data?.staff.find((s) => s.id === remark.targetKey);
|
||||||
|
return member
|
||||||
|
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
||||||
|
: t("review.staffMember", "Staff member");
|
||||||
|
};
|
||||||
|
}, [data?.staff, requirements, localized, roleNameByKey, t]);
|
||||||
|
|
||||||
|
const correction = useMemo(() => {
|
||||||
|
const round = data?.application.adjustmentRound ?? 0;
|
||||||
|
if (!data || round === 0) {
|
||||||
|
return {
|
||||||
|
round,
|
||||||
|
corrected: [],
|
||||||
|
untouched: [],
|
||||||
|
sectionKeys: new Set<string>(),
|
||||||
|
documentKeys: new Set<string>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundRemarks = (data.remarks ?? []).filter(
|
||||||
|
(r) => r.roundNumber === round,
|
||||||
|
);
|
||||||
|
const item = (remark: ApplicationRemark) => ({
|
||||||
|
key: remark.id,
|
||||||
|
label: remarkLabel(remark),
|
||||||
|
remark: remark.remark,
|
||||||
|
changedAt: remark.valueChangedAt ?? remark.resolvedAt ?? remark.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changed = roundRemarks.filter((r) => r.valueChangedAt);
|
||||||
|
return {
|
||||||
|
round,
|
||||||
|
corrected: changed.map(item),
|
||||||
|
// Only once the file is actually back. While it is still with the
|
||||||
|
// applicant the round is being worked on, and calling a not-yet-edited
|
||||||
|
// item "marked done but left unchanged" would be an accusation about
|
||||||
|
// work still in progress.
|
||||||
|
untouched:
|
||||||
|
data.application.status === "RESUBMIT_REQUIRED"
|
||||||
|
? []
|
||||||
|
: roundRemarks
|
||||||
|
.filter((r) => !r.valueChangedAt && r.isResolved)
|
||||||
|
.map(item),
|
||||||
|
// Keyed per target type: a section and a document may legitimately
|
||||||
|
// share a key, and a badge on the wrong one would misdirect the officer.
|
||||||
|
sectionKeys: new Set(
|
||||||
|
changed.filter((r) => r.targetType === "FORM_SECTION").map((r) => r.targetKey),
|
||||||
|
),
|
||||||
|
documentKeys: new Set(
|
||||||
|
changed.filter((r) => r.targetType === "DOCUMENT").map((r) => r.targetKey),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [data, remarkLabel]);
|
||||||
|
|
||||||
const actions = useMemo(() => {
|
const actions = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return resolveActions({
|
return resolveActions({
|
||||||
@@ -393,6 +524,7 @@ export function LicenseReviewPage() {
|
|||||||
flaggedCount: flagged.length,
|
flaggedCount: flagged.length,
|
||||||
hasPendingInspection: Boolean(pendingInspection),
|
hasPendingInspection: Boolean(pendingInspection),
|
||||||
inspectionNotYetDue,
|
inspectionNotYetDue,
|
||||||
|
latestInspectionPassed,
|
||||||
allDocumentsAccepted,
|
allDocumentsAccepted,
|
||||||
reasons: {
|
reasons: {
|
||||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||||
@@ -401,6 +533,10 @@ export function LicenseReviewPage() {
|
|||||||
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
||||||
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
||||||
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
||||||
|
inspectionFailed: t(
|
||||||
|
'review.disabled.inspectionFailed',
|
||||||
|
'The inspection failed — a re-inspection must pass before approval',
|
||||||
|
),
|
||||||
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
|
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
|
||||||
date: pendingInspection?.scheduledDate
|
date: pendingInspection?.scheduledDate
|
||||||
? showDate(pendingInspection.scheduledDate)
|
? showDate(pendingInspection.scheduledDate)
|
||||||
@@ -422,7 +558,19 @@ export function LicenseReviewPage() {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]);
|
}, [
|
||||||
|
data,
|
||||||
|
currentUserId,
|
||||||
|
can,
|
||||||
|
flagged.length,
|
||||||
|
pendingInspection,
|
||||||
|
inspectionNotYetDue,
|
||||||
|
latestInspectionPassed,
|
||||||
|
allDocumentsAccepted,
|
||||||
|
documentProgress,
|
||||||
|
showDate,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
// Location answers are tree ids. The picker the applicant used resolves them
|
// Location answers are tree ids. The picker the applicant used resolves them
|
||||||
// client-side from the same list, so the reviewer reads the place rather than
|
// client-side from the same list, so the reviewer reads the place rather than
|
||||||
@@ -902,9 +1050,8 @@ export function LicenseReviewPage() {
|
|||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => navigate(`/licence-review/${related.id}`)}
|
onClick={() => navigate(`/licence-review/${related.id}`)}
|
||||||
>
|
>
|
||||||
{related.licenseType?.key === "SEAMAN_BOOK"
|
{localized(related.licenseType?.name) ||
|
||||||
? "Seaman Book"
|
related.licenseType?.key}{" "}
|
||||||
: "BTC"}{" "}
|
|
||||||
· {related.applicationNumber} ·{" "}
|
· {related.applicationNumber} ·{" "}
|
||||||
{STATUS_LABELS[related.status]}
|
{STATUS_LABELS[related.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -935,6 +1082,12 @@ export function LicenseReviewPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<CorrectedItemsPanel
|
||||||
|
round={correction.round}
|
||||||
|
items={correction.corrected}
|
||||||
|
untouched={correction.untouched}
|
||||||
|
/>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
{/* Zone 1 — sticky summary rail. */}
|
{/* Zone 1 — sticky summary rail. */}
|
||||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||||
@@ -1130,6 +1283,7 @@ export function LicenseReviewPage() {
|
|||||||
formData={app.formData ?? {}}
|
formData={app.formData ?? {}}
|
||||||
configSections={configSections}
|
configSections={configSections}
|
||||||
currency={app.feeCurrency ?? undefined}
|
currency={app.feeCurrency ?? undefined}
|
||||||
|
correctedKeys={correction.sectionKeys}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
onToggleFlag={(sectionKey) =>
|
onToggleFlag={(sectionKey) =>
|
||||||
toggleFlag("FORM_SECTION", sectionKey)
|
toggleFlag("FORM_SECTION", sectionKey)
|
||||||
@@ -1205,6 +1359,7 @@ export function LicenseReviewPage() {
|
|||||||
attachments={data.attachments}
|
attachments={data.attachments}
|
||||||
requirements={requirements?.documentRequirements ?? []}
|
requirements={requirements?.documentRequirements ?? []}
|
||||||
formData={app.formData ?? {}}
|
formData={app.formData ?? {}}
|
||||||
|
correctedKeys={correction.documentKeys}
|
||||||
flags={documentFlags}
|
flags={documentFlags}
|
||||||
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
|
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
|
||||||
onFlagRemark={(key, remark) =>
|
onFlagRemark={(key, remark) =>
|
||||||
@@ -1330,7 +1485,8 @@ export function LicenseReviewPage() {
|
|||||||
{/* Page-level, not inside the inspection tab: a license type
|
{/* Page-level, not inside the inspection tab: a license type
|
||||||
configured without an inspection detail section must still show
|
configured without an inspection detail section must still show
|
||||||
why approval is blocked if it ever lands here. */}
|
why approval is blocked if it ever lands here. */}
|
||||||
{status === "INSPECTION_FAILED" && (
|
{(status === "INSPECTION_FAILED" ||
|
||||||
|
latestInspectionPassed === false) && (
|
||||||
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
|
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
|
||||||
{t(
|
{t(
|
||||||
"review.inspectionFailedBlocked",
|
"review.inspectionFailedBlocked",
|
||||||
@@ -1358,7 +1514,7 @@ export function LicenseReviewPage() {
|
|||||||
{/* Zone 3 — activity and audit trail. */}
|
{/* Zone 3 — activity and audit trail. */}
|
||||||
{railOpen && (
|
{railOpen && (
|
||||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||||
<ActivityRail detail={data} />
|
<ActivityRail detail={data} remarkLabel={remarkLabel} />
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -1563,17 +1719,18 @@ export function LicenseReviewPage() {
|
|||||||
[item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION",
|
[item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
data={[
|
data={checklistOptions}
|
||||||
{ value: "PASS", label: t("review.checkPass", "Pass") },
|
|
||||||
{
|
|
||||||
value: "NEEDS_CORRECTION",
|
|
||||||
label: t("review.checkFix", "Fix"),
|
|
||||||
},
|
|
||||||
{ value: "FAIL", label: t("review.checkFail", "Fail") },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
{passBlocked && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"review.checklistMustPass",
|
||||||
|
"Every item must pass before the inspection can be recorded as passed.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Textarea
|
<Textarea
|
||||||
label={t("review.findings", "Findings")}
|
label={t("review.findings", "Findings")}
|
||||||
@@ -1653,7 +1810,12 @@ export function LicenseReviewPage() {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="teal"
|
color="teal"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
|
disabled={
|
||||||
|
!findings.trim() ||
|
||||||
|
!pendingInspection ||
|
||||||
|
inspectionNotYetDue ||
|
||||||
|
passBlocked
|
||||||
|
}
|
||||||
aria-label={t("review.passed", "Passed")}
|
aria-label={t("review.passed", "Passed")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
run(
|
run(
|
||||||
@@ -1848,4 +2010,16 @@ function SummaryRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A camelCase or snake_case key as a heading.
|
||||||
|
*
|
||||||
|
* Only reached when the licence type's config has no label for the key — a
|
||||||
|
* section the schema has since dropped, say. Better than printing
|
||||||
|
* `vesselParticulars` at an officer.
|
||||||
|
*/
|
||||||
|
function humaniseKey(key: string): string {
|
||||||
|
const spaced = key.replace(/([A-Z])/g, " $1").replace(/[_-]+/g, " ");
|
||||||
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
export default LicenseReviewPage;
|
export default LicenseReviewPage;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useConfirmSeafarerDocumentPaymentMutation,
|
useConfirmSeafarerDocumentPaymentMutation,
|
||||||
|
useGetLicenseTypesQuery,
|
||||||
useGetSeafarerDocumentReviewQuery,
|
useGetSeafarerDocumentReviewQuery,
|
||||||
useIssueSeafarerDocumentMutation,
|
useIssueSeafarerDocumentMutation,
|
||||||
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
||||||
@@ -60,6 +61,9 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const showDate = useDateDisplayer();
|
const showDate = useDateDisplayer();
|
||||||
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
|
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
|
||||||
|
// The document's policy lives on its licence-type row (the kind is the
|
||||||
|
// type's key), as configured on the Behaviour tab.
|
||||||
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||||
|
|
||||||
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
|
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
|
||||||
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
|
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
|
||||||
@@ -90,6 +94,11 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { document, applicant, payment } = data;
|
const { document, applicant, payment } = data;
|
||||||
|
// Collected in person unless the type says otherwise: then payment
|
||||||
|
// confirmation issues it and there is nothing to schedule.
|
||||||
|
const collectedInPerson =
|
||||||
|
licenseTypes?.items.find((type) => type.key === document.kind)
|
||||||
|
?.requiresIssuanceScheduling ?? true;
|
||||||
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||||
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
|
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
|
||||||
|
|
||||||
@@ -156,12 +165,13 @@ export function SeafarerDocumentReviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
{document.status === 'PAYMENT_CONFIRMED' && (
|
{document.status === 'PAYMENT_CONFIRMED' && collectedInPerson && (
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||||
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
|
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
)}
|
)}
|
||||||
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
|
{(document.status === 'SCHEDULED' ||
|
||||||
|
(document.status === 'PAYMENT_CONFIRMED' && !collectedInPerson)) && (
|
||||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||||
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
|
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
|
||||||
Issue
|
Issue
|
||||||
|
|||||||
@@ -104,10 +104,7 @@ const UM_CONFIG: DesignConfig = {
|
|||||||
const UM_RUNTIME = {
|
const UM_RUNTIME = {
|
||||||
basename: "/um",
|
basename: "/um",
|
||||||
// Keep the embedded IAM module on the same API as the backoffice client.
|
// Keep the embedded IAM module on the same API as the backoffice client.
|
||||||
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
|
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "https://ema-api-dev.triaplc.com/api",
|
||||||
// fall back to its remote development server, where the local JWT is
|
|
||||||
// rejected and the module redirects to its login page.
|
|
||||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonStyle: React.CSSProperties = {
|
const buttonStyle: React.CSSProperties = {
|
||||||
|
|||||||
@@ -726,11 +726,19 @@ export const am: Translations = {
|
|||||||
descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
|
descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
|
||||||
descAm: "መግለጫ (አማርኛ)",
|
descAm: "መግለጫ (አማርኛ)",
|
||||||
descAmPlaceholder: "የአማርኛ መግለጫ",
|
descAmPlaceholder: "የአማርኛ መግለጫ",
|
||||||
|
rankKey: "የSTCW ማዕረግ (ለፈተና መርሐግብር)",
|
||||||
|
rankKeyHint: "ይህ የምስክር ወረቀት የሚፈተነው የCoC/CoP መሰላል አካል ካልሆነ ባዶ ይተዉት።",
|
||||||
|
rankKeyPlaceholder: "ለተወሰነ ማዕረግ አይደለም",
|
||||||
|
isActive: "ንቁ",
|
||||||
|
isActiveHint: "ንቁ ያልሆኑ የምስክር ወረቀቶች በነባር ፈተናዎች ላይ ይቆያሉ፤ ለአዲስ ፈተናዎች ግን አይቀርቡም።",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
active: "ንቁ",
|
active: "ንቁ",
|
||||||
inactive: "እንቅስቃሴ የሌለ",
|
inactive: "እንቅስቃሴ የሌለ",
|
||||||
},
|
},
|
||||||
|
validation: {
|
||||||
|
nameRequired: "የእንግሊዝኛ እና የአማርኛ ስሞች ሁለቱም ያስፈልጋሉ",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
result: {
|
result: {
|
||||||
@@ -1005,6 +1013,88 @@ export const am: Translations = {
|
|||||||
|
|
||||||
configuration: {
|
configuration: {
|
||||||
title: "ውቅረት",
|
title: "ውቅረት",
|
||||||
|
licenseTypesTab: "የፈቃድ አይነቶች",
|
||||||
|
licenseTypes: {
|
||||||
|
add: "የፈቃድ ዓይነት ያክሉ",
|
||||||
|
edit: "የፈቃድ ዓይነት አስተካክል",
|
||||||
|
key: "ቁልፍ",
|
||||||
|
keyHint:
|
||||||
|
"ቋሚ መለያ፣ ለምሳሌ CUSTOMS_BROKER። ማመልከቻዎች ከጠቀሱት በኋላ ሊቀየር አይችልም።",
|
||||||
|
keyInvalid: "አቢይ ፊደላት፣ አሃዞችና ከስር መስመር፣ ከ3 እስከ 64 ቁምፊዎች",
|
||||||
|
category: "ምድብ",
|
||||||
|
categoryHint:
|
||||||
|
"እነማን ማመልከት እንደሚችሉና የትኞቹ የሹመት ደረጃዎች እርምጃ መውሰድ እንደሚችሉ ይወስናል።",
|
||||||
|
familyKind: "ዓይነት",
|
||||||
|
familyKindHint:
|
||||||
|
"ፈቃድ፣ የምስክር ወረቀት ወይም ሰነድ። አጠቃቀሙን፣ የአመልካች ካታሎጉንና ወረፋው ድርጅት እንደሚያሳይ ይወስናል።",
|
||||||
|
prefix: "የምስክር ወረቀት ቅድመ ቅጥያ",
|
||||||
|
prefixHint:
|
||||||
|
"የእያንዳንዱ ማመልከቻና የምስክር ወረቀት ቁጥር መጀመሪያ፣ ለምሳሌ CB → CB-2026-000042።",
|
||||||
|
prefixInvalid: "ያስፈልጋል፣ ቢበዛ 12 ቁምፊዎች",
|
||||||
|
status: "ሁኔታ",
|
||||||
|
active: "ንቁ",
|
||||||
|
inactive: "እንቅስቃሴ የሌለ",
|
||||||
|
isActive: "ለአመልካቾች ይታያል",
|
||||||
|
isActiveHint: "ቅጹና የሰነድ መስፈርቶቹ እስኪዘጋጁ ድረስ አጥፍተው ይተዉት።",
|
||||||
|
activate: "አንቃ",
|
||||||
|
deactivate: "አሰናክል",
|
||||||
|
activateTitle: "የፈቃድ ዓይነት አንቃ",
|
||||||
|
deactivateTitle: "የፈቃድ ዓይነት አሰናክል",
|
||||||
|
activateText: "{{name}} ለአመልካቾች እንደገና ይቀርባል።",
|
||||||
|
deactivateText:
|
||||||
|
"{{name}} ከአመልካች ካታሎግ ይጠፋል። በሂደት ላይ ያሉ ማመልከቻዎች ሳይነኩ ይቀጥላሉ።",
|
||||||
|
activated: "የፈቃድ ዓይነቱ አሁን ማመልከቻዎችን ይቀበላል",
|
||||||
|
deactivated: "የፈቃድ ዓይነቱ ለአዲስ ማመልከቻዎች ተዘግቷል",
|
||||||
|
noPermission: "የፈቃድ ዓይነት ለመፍጠር ፈቃድ የለዎትም።",
|
||||||
|
nextStepsTitle: "ዓይነቱን ከፈጠሩ በኋላ",
|
||||||
|
nextSteps:
|
||||||
|
"ቅጹን፣ የሰነድ መስፈርቶቹንና ባህሪውን በምስክር ወረቀት መስፈርቶች፣ ክፍያዎቹን በክፍያ ውቅረት፣ የምስክር ወረቀት ንድፉን ደግሞ በንድፍ ሰሪው ያዘጋጁ — ከዚያ እዚህ ንቁ ያድርጉት።",
|
||||||
|
empty: "እስካሁን ምንም የፈቃድ አይነቶች የሉም",
|
||||||
|
notice:
|
||||||
|
"አዲስ አይነት ባዶ ሆኖ ይጀምራል። ከፈጠሩ በኋላ ቅጹንና የሰነድ መስፈርቶቹን በምስክር ወረቀት መስፈርቶች ስር፣ ክፍያዎቹን ደግሞ በክፍያ ውቅረት ስር ያዘጋጁ።",
|
||||||
|
goToRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||||
|
goToFees: "የክፍያ ውቅረት",
|
||||||
|
created: "የፈቃድ አይነት ተፈጥሯል። በመቀጠል ቅጹን፣ ሰነዶቹንና ክፍያዎቹን ያዘጋጁ።",
|
||||||
|
toggleAria: "ይህ የፈቃድ አይነት ማመልከቻ መቀበል አለመቀበሉን ይቀያይሩ",
|
||||||
|
familyHint: "የትኛው ክፍል እንደሚያስተዳድረው እና በየትኛው የፖርታል ካታሎግ እንደሚታይ ይወስናል።",
|
||||||
|
fee: "የአዲስ ማመልከቻ ክፍያ",
|
||||||
|
feeHint: "ክፍያ ከሌለው ባዶ ይተዉት።",
|
||||||
|
currency: "ምንዛሪ",
|
||||||
|
validity: "የአገልግሎት ጊዜ (በወራት)",
|
||||||
|
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
|
||||||
|
renewalEnabled: "ሊታደስ የሚችል",
|
||||||
|
inspectionRequired: "ምርመራ ያስፈልጋል",
|
||||||
|
columns: {
|
||||||
|
category: "ምድብ",
|
||||||
|
family: "ቤተሰብ",
|
||||||
|
prefix: "ቅድመ ቅጥያ",
|
||||||
|
status: "ሁኔታ",
|
||||||
|
actions: "እርምጃዎች",
|
||||||
|
},
|
||||||
|
family: {
|
||||||
|
LOGISTICS_LICENSE: "የሎጂስቲክስ ፈቃድ",
|
||||||
|
CERTIFICATE: "የባህርተኛ የምስክር ወረቀት",
|
||||||
|
DOCUMENT: "የማንነት / ህጋዊ ሰነድ",
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
LICENSE: "ፈቃድ",
|
||||||
|
REGISTRATION: "ምዝገባ",
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
STANDARD: "መደበኛ (ግምገማ → ምዘና → ምርመራ → ማጽደቅ)",
|
||||||
|
REGISTRATION: "ምዝገባ (ግምገማ → ማጽደቅ)",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
keyRequired: "ቁልፍ ያስፈልጋል",
|
||||||
|
keyTooLong: "ቁልፍ ከ64 ቁምፊዎች መብለጥ የለበትም",
|
||||||
|
keyFormat: "ፊደላት፣ አሃዞችና ከስር መስመር ብቻ ይጠቀሙ፣ ለምሳሌ PORT_AGENT",
|
||||||
|
keyTaken: "በዚህ ቁልፍ የፈቃድ አይነት ቀድሞ አለ",
|
||||||
|
prefixRequired: "የምስክር ወረቀት ቅድመ ቅጥያ ያስፈልጋል",
|
||||||
|
prefixTooLong: "ቅድመ ቅጥያ ከ12 ቁምፊዎች መብለጥ የለበትም",
|
||||||
|
currencyTooLong: "አጭር የምንዛሪ ኮድ ይጠቀሙ",
|
||||||
|
validityRange: "የአገልግሎት ጊዜ በ1 እና 240 ወራት መካከል መሆን አለበት",
|
||||||
|
},
|
||||||
|
},
|
||||||
personalDocumentsTab: "የግል ሰነዶች",
|
personalDocumentsTab: "የግል ሰነዶች",
|
||||||
departments: "ክፍሎች",
|
departments: "ክፍሎች",
|
||||||
professions: "ሙያዎች",
|
professions: "ሙያዎች",
|
||||||
@@ -1040,6 +1130,7 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
queue: {
|
queue: {
|
||||||
title: "የፈቃድ ማመልከቻዎች",
|
title: "የፈቃድ ማመልከቻዎች",
|
||||||
titleByFamily: "{{family}} ማመልከቻዎች",
|
titleByFamily: "{{family}} ማመልከቻዎች",
|
||||||
@@ -1160,6 +1251,15 @@ export const am: Translations = {
|
|||||||
deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።",
|
deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።",
|
||||||
notificationPreview: "ለአመልካቹ የሚላክ መልእክት",
|
notificationPreview: "ለአመልካቹ የሚላክ መልእክት",
|
||||||
notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።",
|
notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።",
|
||||||
|
corrected: {
|
||||||
|
title_one: "በዙር {{round}} 1 ነጥብ ተስተካክሏል",
|
||||||
|
title_other: "በዙር {{round}} {{count}} ነጥቦች ተስተካክለዋል",
|
||||||
|
untouchedBadge_one: "1 አልተለወጠም",
|
||||||
|
untouchedBadge_other: "{{count}} አልተለወጡም",
|
||||||
|
untouched: "እንደተጠናቀቀ ምልክት ተደርጎ ግን አልተለወጠም — መጀመሪያ እነዚህን ይመልከቱ",
|
||||||
|
against: "የተጠየቀው፦ {{remark}}",
|
||||||
|
badge: "ተስተካክሏል",
|
||||||
|
},
|
||||||
needsCorrection: "ማስተካከያ ያስፈልገዋል",
|
needsCorrection: "ማስተካከያ ያስፈልገዋል",
|
||||||
correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?",
|
correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?",
|
||||||
verifiedCapital: "የተረጋገጠ ካፒታል (ብር)",
|
verifiedCapital: "የተረጋገጠ ካፒታል (ብር)",
|
||||||
@@ -1242,6 +1342,7 @@ export const am: Translations = {
|
|||||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||||
|
inspectionFailed: "ምርመራው አልተሳካም — ከመጽደቁ በፊት ድጋሚ ምርመራ ማለፍ አለበት",
|
||||||
inspectionNotYetDue:
|
inspectionNotYetDue:
|
||||||
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
|
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
|
||||||
needsDocumentReviews:
|
needsDocumentReviews:
|
||||||
@@ -1249,7 +1350,9 @@ export const am: Translations = {
|
|||||||
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
||||||
},
|
},
|
||||||
inspectionFailedBlocked:
|
inspectionFailedBlocked:
|
||||||
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
"ምርመራው ስላልተሳካ ማጽደቅና ሰርተፍኬት መስጠት አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
||||||
|
checklistMustPass:
|
||||||
|
"ምርመራው እንደተሳካ ከመመዝገቡ በፊት ሁሉም ነጥቦች ማለፍ አለባቸው።",
|
||||||
reasons: {
|
reasons: {
|
||||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||||
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
|
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
|
||||||
@@ -1295,6 +1398,7 @@ export const am: Translations = {
|
|||||||
officer: "ሹም",
|
officer: "ሹም",
|
||||||
applicant: "አመልካች",
|
applicant: "አመልካች",
|
||||||
remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል",
|
remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል",
|
||||||
|
correctedTarget: "{{target}} ተስተካክሏል",
|
||||||
uploaded: "{{document}} ተጭኗል",
|
uploaded: "{{document}} ተጭኗል",
|
||||||
},
|
},
|
||||||
documents: {
|
documents: {
|
||||||
@@ -1657,6 +1761,21 @@ export const am: Translations = {
|
|||||||
maxFilesUnlimited: "ገደብ የለም",
|
maxFilesUnlimited: "ገደብ የለም",
|
||||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||||
when: "መቼ",
|
when: "መቼ",
|
||||||
|
importPersonal: "ከግል ሰነዶች አስመጣ",
|
||||||
|
importPersonalDesc:
|
||||||
|
"ለዚህ የፈቃድ አይነት እንደ መስፈርት ለማከል በውቅር ገጽ ላይ ከተዋቀሩት የግል ሰነዶች ይምረጡ።",
|
||||||
|
importFromPersonal: "ከግል ሰነድ አስመጣ",
|
||||||
|
selectPersonalDoc: "ዝርዝሩን ለመቅዳት የግል ሰነድ ይምረጡ…",
|
||||||
|
importSelected: "የተመረጡትን አስመጣ ({{count}})",
|
||||||
|
importAction: "አስመጣ",
|
||||||
|
customizeAndAdd: "አስተካክለህ ጨምር",
|
||||||
|
alreadyAdded: "ቀደም ሲል ተጨምሯል",
|
||||||
|
noPersonalDocs: "በውቅር ውስጥ የተዘጋጀ የግል ሰነድ እስካሁን የለም።",
|
||||||
|
importedFromPersonal: "ከግል ሰነድ የመጣ: {{name}}",
|
||||||
|
importedHelper:
|
||||||
|
"ከግል ሰነዶች የመጣ። ቁልፉ ከአመልካቹ ሰነዶች ጋር ስለሚዛመድ ፋይሎች በቀጥታ ይገናኛሉ።",
|
||||||
|
importMode: "ለሚመጡ ሰነዶች የመስፈርት ሁነታ",
|
||||||
|
importSuccess: "{{count}} የግል ሰነድ(ዶች) ገብተዋል",
|
||||||
},
|
},
|
||||||
personal: {
|
personal: {
|
||||||
title: "የግል ሰነዶች",
|
title: "የግል ሰነዶች",
|
||||||
|
|||||||
@@ -718,6 +718,13 @@ export const en = {
|
|||||||
description: 'Description',
|
description: 'Description',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
nameRequired: 'Both English and Amharic names are required',
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
nameEn: 'Name (English)',
|
nameEn: 'Name (English)',
|
||||||
nameEnPlaceholder: 'Certificate name in English',
|
nameEnPlaceholder: 'Certificate name in English',
|
||||||
@@ -727,10 +734,11 @@ export const en = {
|
|||||||
descEnPlaceholder: 'English description',
|
descEnPlaceholder: 'English description',
|
||||||
descAm: 'Description (Amharic)',
|
descAm: 'Description (Amharic)',
|
||||||
descAmPlaceholder: 'የአማርኛ መግለጫ',
|
descAmPlaceholder: 'የአማርኛ መግለጫ',
|
||||||
},
|
rankKey: 'STCW rank (for exam scheduling)',
|
||||||
status: {
|
rankKeyHint: 'Leave blank if this certification is not part of the examined CoC/CoP ladder.',
|
||||||
active: 'Active',
|
rankKeyPlaceholder: 'Not rank-specific',
|
||||||
inactive: 'Inactive',
|
isActive: 'Active',
|
||||||
|
isActiveHint: 'Inactive certifications stay on existing exams but are not offered for new ones.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1010,6 +1018,89 @@ export const en = {
|
|||||||
|
|
||||||
configuration: {
|
configuration: {
|
||||||
title: 'Configuration',
|
title: 'Configuration',
|
||||||
|
licenseTypesTab: 'Licence Types',
|
||||||
|
licenseTypes: {
|
||||||
|
add: 'Add licence type',
|
||||||
|
edit: 'Edit licence type',
|
||||||
|
key: 'Key',
|
||||||
|
keyHint:
|
||||||
|
'Permanent identifier, e.g. CUSTOMS_BROKER. Cannot be changed once applications reference it.',
|
||||||
|
keyInvalid: 'Upper-case letters, digits and underscores, 3–64 characters',
|
||||||
|
category: 'Category',
|
||||||
|
categoryHint:
|
||||||
|
'Decides which applicants may apply and which officer positions can act on it.',
|
||||||
|
familyKind: 'Kind',
|
||||||
|
familyKindHint:
|
||||||
|
'Licence, certificate or document. Drives the wording, the applicant catalogue and whether the queue shows a company.',
|
||||||
|
prefix: 'Certificate prefix',
|
||||||
|
prefixHint:
|
||||||
|
'Front of every application and certificate number, e.g. CB → CB-2026-000042.',
|
||||||
|
prefixInvalid: 'Required, at most 12 characters',
|
||||||
|
status: 'Status',
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
isActive: 'Visible to applicants',
|
||||||
|
isActiveHint:
|
||||||
|
'Leave off until the form and document requirements are configured.',
|
||||||
|
activate: 'Activate',
|
||||||
|
deactivate: 'Deactivate',
|
||||||
|
activateTitle: 'Activate licence type',
|
||||||
|
deactivateTitle: 'Deactivate licence type',
|
||||||
|
activateText: '{{name}} will be offered to applicants again.',
|
||||||
|
deactivateText:
|
||||||
|
'{{name}} will disappear from the applicant catalogue. Applications already in progress continue unaffected.',
|
||||||
|
activated: 'Licence type is now accepting applications',
|
||||||
|
deactivated: 'Licence type is closed to new applications',
|
||||||
|
noPermission: 'You do not have permission to create licence types.',
|
||||||
|
nextStepsTitle: 'After creating a type',
|
||||||
|
nextSteps:
|
||||||
|
'Configure its form, document requirements and behaviour on Certificate requirements, its fees on Payment configuration, and its certificate design in the designer — then switch it active here.',
|
||||||
|
empty: 'No licence types yet',
|
||||||
|
notice:
|
||||||
|
'A new type starts empty. After creating it, set up its form and document slots under Certificate Requirements, and its fees under Payment Configuration.',
|
||||||
|
goToRequirements: 'Certificate requirements',
|
||||||
|
goToFees: 'Payment configuration',
|
||||||
|
created: 'Licence type created. Configure its form, documents and fees next.',
|
||||||
|
toggleAria: 'Toggle whether this licence type accepts applications',
|
||||||
|
familyHint: 'Decides which desk owns it and which portal catalogue lists it.',
|
||||||
|
fee: 'New application fee',
|
||||||
|
feeHint: 'Leave blank for no charge.',
|
||||||
|
currency: 'Currency',
|
||||||
|
validity: 'Validity (months)',
|
||||||
|
issuesCertificate: 'Issues a certificate',
|
||||||
|
renewalEnabled: 'Renewable',
|
||||||
|
inspectionRequired: 'Inspection required',
|
||||||
|
columns: {
|
||||||
|
category: 'Category',
|
||||||
|
family: 'Family',
|
||||||
|
prefix: 'Prefix',
|
||||||
|
status: 'Status',
|
||||||
|
actions: 'Actions',
|
||||||
|
},
|
||||||
|
family: {
|
||||||
|
LOGISTICS_LICENSE: 'Logistics licence',
|
||||||
|
CERTIFICATE: 'Seafarer certificate',
|
||||||
|
DOCUMENT: 'Identity / statutory document',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
LICENSE: 'Licence',
|
||||||
|
REGISTRATION: 'Registration',
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
STANDARD: 'Standard (review → evaluation → inspection → approval)',
|
||||||
|
REGISTRATION: 'Registration (review → approval)',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
keyRequired: 'Key is required',
|
||||||
|
keyTooLong: 'Key must be at most 64 characters',
|
||||||
|
keyFormat: 'Use letters, digits and underscores, e.g. PORT_AGENT',
|
||||||
|
keyTaken: 'A licence type with this key already exists',
|
||||||
|
prefixRequired: 'Certificate prefix is required',
|
||||||
|
prefixTooLong: 'Prefix must be at most 12 characters',
|
||||||
|
currencyTooLong: 'Use a short currency code',
|
||||||
|
validityRange: 'Validity must be between 1 and 240 months',
|
||||||
|
},
|
||||||
|
},
|
||||||
personalDocumentsTab: 'Personal Documents',
|
personalDocumentsTab: 'Personal Documents',
|
||||||
departments: 'Departments',
|
departments: 'Departments',
|
||||||
professions: 'Professions',
|
professions: 'Professions',
|
||||||
@@ -1170,6 +1261,15 @@ export const en = {
|
|||||||
deficienciesHint: 'Only the ticked items become editable for the applicant.',
|
deficienciesHint: 'Only the ticked items become editable for the applicant.',
|
||||||
notificationPreview: 'Message to the applicant',
|
notificationPreview: 'Message to the applicant',
|
||||||
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
|
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
|
||||||
|
corrected: {
|
||||||
|
title_one: '1 item corrected in round {{round}}',
|
||||||
|
title_other: '{{count}} items corrected in round {{round}}',
|
||||||
|
untouchedBadge_one: '1 unchanged',
|
||||||
|
untouchedBadge_other: '{{count}} unchanged',
|
||||||
|
untouched: 'Marked done but left unchanged — re-read these first',
|
||||||
|
against: 'Asked: {{remark}}',
|
||||||
|
badge: 'Corrected',
|
||||||
|
},
|
||||||
needsCorrection: 'Needs correction',
|
needsCorrection: 'Needs correction',
|
||||||
correctionPlaceholder: 'What must the applicant correct?',
|
correctionPlaceholder: 'What must the applicant correct?',
|
||||||
verifiedCapital: 'Verified capital (ETB)',
|
verifiedCapital: 'Verified capital (ETB)',
|
||||||
@@ -1252,6 +1352,8 @@ export const en = {
|
|||||||
needsFlags: 'Flag at least one item to request a correction',
|
needsFlags: 'Flag at least one item to request a correction',
|
||||||
needsCapital: 'Record the verified capital first',
|
needsCapital: 'Record the verified capital first',
|
||||||
needsInspection: 'Requires an inspection result',
|
needsInspection: 'Requires an inspection result',
|
||||||
|
inspectionFailed:
|
||||||
|
'The inspection failed — a re-inspection must pass before approval',
|
||||||
inspectionNotYetDue:
|
inspectionNotYetDue:
|
||||||
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
|
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
|
||||||
needsDocumentReviews:
|
needsDocumentReviews:
|
||||||
@@ -1259,7 +1361,9 @@ export const en = {
|
|||||||
needsDocumentsUploaded: 'No documents uploaded to review yet',
|
needsDocumentsUploaded: 'No documents uploaded to review yet',
|
||||||
},
|
},
|
||||||
inspectionFailedBlocked:
|
inspectionFailedBlocked:
|
||||||
'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
|
'Approval and certificate issuance are unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
|
||||||
|
checklistMustPass:
|
||||||
|
'Every item must pass before the inspection can be recorded as passed.',
|
||||||
reasons: {
|
reasons: {
|
||||||
incompleteDocuments: 'Incomplete documents',
|
incompleteDocuments: 'Incomplete documents',
|
||||||
belowCapital: 'Capital below the required minimum',
|
belowCapital: 'Capital below the required minimum',
|
||||||
@@ -1302,6 +1406,7 @@ export const en = {
|
|||||||
officer: 'Officer',
|
officer: 'Officer',
|
||||||
applicant: 'Applicant',
|
applicant: 'Applicant',
|
||||||
remarkOn: 'Correction requested on {{target}}',
|
remarkOn: 'Correction requested on {{target}}',
|
||||||
|
correctedTarget: 'Corrected {{target}}',
|
||||||
uploaded: 'Uploaded {{document}}',
|
uploaded: 'Uploaded {{document}}',
|
||||||
},
|
},
|
||||||
documents: {
|
documents: {
|
||||||
@@ -1664,6 +1769,21 @@ export const en = {
|
|||||||
maxFilesUnlimited: 'No limit',
|
maxFilesUnlimited: 'No limit',
|
||||||
sortOrder: 'Sort order',
|
sortOrder: 'Sort order',
|
||||||
when: 'when',
|
when: 'when',
|
||||||
|
importPersonal: 'Import from personal documents',
|
||||||
|
importPersonalDesc:
|
||||||
|
'Select documents configured in Personal Documents to add as requirements for this licence type.',
|
||||||
|
importFromPersonal: 'Import from personal document',
|
||||||
|
selectPersonalDoc: 'Select a personal document to copy details…',
|
||||||
|
importSelected: 'Import selected ({{count}})',
|
||||||
|
importAction: 'Import',
|
||||||
|
customizeAndAdd: 'Customize & add',
|
||||||
|
alreadyAdded: 'Already added',
|
||||||
|
noPersonalDocs: 'No personal documents configured in Configuration yet.',
|
||||||
|
importedFromPersonal: 'Imported from personal document: {{name}}',
|
||||||
|
importedHelper:
|
||||||
|
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||||
|
importMode: 'Requirement mode for imported documents',
|
||||||
|
importSuccess: 'Imported {{count}} personal document(s)',
|
||||||
},
|
},
|
||||||
personal: {
|
personal: {
|
||||||
title: 'Personal documents',
|
title: 'Personal documents',
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export default defineConfig({
|
|||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://ema-api-dev.triaplc.com',
|
target: process.env.VITE_API_PROXY_TARGET || 'https://ema-api-dev.triaplc.com',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ import {
|
|||||||
useApiQuery,
|
useApiQuery,
|
||||||
useBypassPaymentMutation,
|
useBypassPaymentMutation,
|
||||||
useDiscardApplicationMutation,
|
useDiscardApplicationMutation,
|
||||||
|
useGetLicenseTypesQuery,
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
|
type LicenseType,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
useGetMySeaServiceRecordsQuery,
|
useGetMySeaServiceRecordsQuery,
|
||||||
@@ -146,6 +148,45 @@ function formatDate(value: string | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
|
||||||
|
|
||||||
|
async function generateCertificate(profileId: string): Promise<Blob> {
|
||||||
|
const token = authStorage.getToken();
|
||||||
|
if (!token) throw new Error('No auth token found');
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A licence type's validity the way a certificate would print it: days win
|
||||||
|
* over months, whole years read as years.
|
||||||
|
*/
|
||||||
|
function validityLabel(type: LicenseType): string {
|
||||||
|
if (type.validityDays) {
|
||||||
|
return `${type.validityDays} ${type.validityDays === 1 ? 'day' : 'days'}`;
|
||||||
|
}
|
||||||
|
const months = type.validityMonths || 12;
|
||||||
|
if (months % 12 === 0) {
|
||||||
|
const years = months / 12;
|
||||||
|
return `${years} ${years === 1 ? 'year' : 'years'}`;
|
||||||
|
}
|
||||||
|
return `${months} ${months === 1 ? 'month' : 'months'}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function CertificatesPage() {
|
export function CertificatesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
@@ -234,6 +275,33 @@ export function CertificatesPage() {
|
|||||||
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
|
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
|
||||||
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
|
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
|
||||||
|
|
||||||
|
// The certificate types on offer and how long each is valid come from the
|
||||||
|
// licence-type rows the backoffice configures (certificate category and
|
||||||
|
// validity on the Behaviour tab), not from a list kept here.
|
||||||
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||||
|
const certificateTypes = (licenseTypes?.items ?? []).filter(
|
||||||
|
(type) =>
|
||||||
|
type.isActive !== false &&
|
||||||
|
(type.certificateCategory === 'COC' || type.certificateCategory === 'COP'),
|
||||||
|
);
|
||||||
|
const applyTargets =
|
||||||
|
certificateTypes.length > 0
|
||||||
|
? certificateTypes.map((type) => ({
|
||||||
|
key: type.key,
|
||||||
|
label: type.certificateCategory === 'COC' ? 'CoC' : 'CoP',
|
||||||
|
variant: (type.certificateCategory === 'COC' ? 'filled' : 'light') as 'filled' | 'light',
|
||||||
|
}))
|
||||||
|
: // Before the catalogue loads, or on a server that has not seeded it.
|
||||||
|
[
|
||||||
|
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
|
||||||
|
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
|
||||||
|
];
|
||||||
|
const validityTerms = [...new Set(certificateTypes.map(validityLabel))];
|
||||||
|
const validityDescription =
|
||||||
|
validityTerms.length > 0
|
||||||
|
? `CoC/CoP certificates are valid for ${validityTerms.join(' or ')} and must be revalidated before expiry.`
|
||||||
|
: 'CoC/CoP certificates are valid for a fixed term and must be revalidated before expiry.';
|
||||||
|
|
||||||
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
|
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
|
||||||
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
|
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
|
||||||
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
|
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
|
||||||
@@ -296,10 +364,7 @@ export function CertificatesPage() {
|
|||||||
disabled, so the reason still shows on hover. */}
|
disabled, so the reason still shows on hover. */}
|
||||||
<span>
|
<span>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{([
|
{applyTargets.map(({ key, label, variant }) => {
|
||||||
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
|
|
||||||
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
|
|
||||||
]).map(({ key, label, variant }) => {
|
|
||||||
const hasDraft = draftTypeKeys.has(key);
|
const hasDraft = draftTypeKeys.has(key);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -337,7 +402,7 @@ export function CertificatesPage() {
|
|||||||
{[
|
{[
|
||||||
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
|
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
|
||||||
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
|
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
|
||||||
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
|
{ icon: IconClock, color: 'orange', title: 'Validity', desc: validityDescription },
|
||||||
].map(({ icon: Icon, color, title, desc }) => (
|
].map(({ icon: Icon, color, title, desc }) => (
|
||||||
<Card key={title} withBorder radius="md" p="sm">
|
<Card key={title} withBorder radius="md" p="sm">
|
||||||
<Group gap="xs" mb={4}>
|
<Group gap="xs" mb={4}>
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
ColorSwatch,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconCategory, IconChartPie, IconFileAnalytics, IconTrendingUp } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
export interface StatusDistributionItem {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyTrendItem {
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
submitted: number;
|
||||||
|
approved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryBreakdownItem {
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardChartsProps {
|
||||||
|
statusDistribution: StatusDistributionItem[];
|
||||||
|
monthlyTrend: MonthlyTrendItem[];
|
||||||
|
categoryBreakdown?: CategoryBreakdownItem[];
|
||||||
|
totalApplications: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = ['#2a59ac', '#1bb497', '#4c6ef5', '#fab005', '#e64980', '#12b886'];
|
||||||
|
|
||||||
|
export function DashboardCharts({
|
||||||
|
monthlyTrend,
|
||||||
|
categoryBreakdown = [],
|
||||||
|
totalApplications,
|
||||||
|
}: DashboardChartsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [viewMode, setViewMode] = useState<'timeline' | 'category'>('timeline');
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: isDark ? 'var(--mantine-color-dark-7, #1a1b1e)' : '#ffffff',
|
||||||
|
border: isDark
|
||||||
|
? '1px solid var(--mantine-color-dark-4, #373a40)'
|
||||||
|
: '1px solid var(--mantine-color-gray-2, #e9ecef)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 4px 16px rgba(0, 0, 0, 0.4)'
|
||||||
|
: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: isDark ? '#c1c2c5' : '#212529',
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridColor = isDark ? 'var(--mantine-color-dark-5, #2c2e33)' : 'var(--mantine-color-gray-2, #e9ecef)';
|
||||||
|
const tickColor = isDark ? '#909296' : '#868e96';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" align="center" mb="md" wrap="wrap" gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" size="md" radius="md">
|
||||||
|
{viewMode === 'timeline' ? <IconTrendingUp size={18} /> : <IconCategory size={18} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
{viewMode === 'timeline'
|
||||||
|
? t('dashboard.charts.activityTitle')
|
||||||
|
: t('dashboard.charts.categoryTitle')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{viewMode === 'timeline'
|
||||||
|
? t('dashboard.charts.activitySubtitle')
|
||||||
|
: t('dashboard.charts.categoryCount')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{totalApplications > 0 && (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(val) => setViewMode(val as 'timeline' | 'category')}
|
||||||
|
data={[
|
||||||
|
{ label: t('dashboard.charts.viewTimeline'), value: 'timeline' },
|
||||||
|
{ label: t('dashboard.charts.viewCategory'), value: 'category' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box style={{ height: 220, width: '100%' }}>
|
||||||
|
{totalApplications === 0 ? (
|
||||||
|
<Center h={200}>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
|
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||||
|
<IconFileAnalytics size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('dashboard.charts.noData')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : viewMode === 'timeline' ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={monthlyTrend} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaSubmissions" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
|
||||||
|
stopOpacity={isDark ? 0.5 : 0.35}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
|
||||||
|
stopOpacity={0.0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="areaApproved" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
|
||||||
|
stopOpacity={isDark ? 0.5 : 0.35}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
|
||||||
|
stopOpacity={0.0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridColor} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 11, fill: tickColor }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: tickColor }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value: unknown, name: unknown) => [
|
||||||
|
value as string | number,
|
||||||
|
name === 'submitted'
|
||||||
|
? t('dashboard.charts.submitted')
|
||||||
|
: t('dashboard.charts.approved'),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
|
||||||
|
strokeWidth={2}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#areaSubmissions)"
|
||||||
|
name="submitted"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="approved"
|
||||||
|
stroke={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
|
||||||
|
strokeWidth={2}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#areaApproved)"
|
||||||
|
name="approved"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={categoryBreakdown} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridColor} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="category"
|
||||||
|
tick={{ fontSize: 11, fill: tickColor }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: tickColor }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value: unknown) => [value as string | number, t('dashboard.charts.categoryCount')]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||||
|
{categoryBreakdown.map((_entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`bar-${index}`}
|
||||||
|
fill={isDark ? '#4dabf7' : CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusDonutCard({
|
||||||
|
statusDistribution,
|
||||||
|
totalApplications,
|
||||||
|
}: {
|
||||||
|
statusDistribution: StatusDistributionItem[];
|
||||||
|
totalApplications: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
|
const nonZeroSlices = statusDistribution.filter((item) => item.value > 0);
|
||||||
|
const pieData =
|
||||||
|
nonZeroSlices.length > 0
|
||||||
|
? nonZeroSlices
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: t('dashboard.charts.total'),
|
||||||
|
value: 1,
|
||||||
|
color: isDark ? 'var(--mantine-color-dark-4, #373a40)' : '#e9ecef',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: isDark ? 'var(--mantine-color-dark-7, #1a1b1e)' : '#ffffff',
|
||||||
|
border: isDark
|
||||||
|
? '1px solid var(--mantine-color-dark-4, #373a40)'
|
||||||
|
: '1px solid var(--mantine-color-gray-2, #e9ecef)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 4px 16px rgba(0, 0, 0, 0.4)'
|
||||||
|
: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: isDark ? '#c1c2c5' : '#212529',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
{t('dashboard.charts.statusTitle')}
|
||||||
|
</Text>
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" size="sm" radius="md">
|
||||||
|
<IconChartPie size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Flex
|
||||||
|
direction={{ base: 'column', xs: 'row', sm: 'row' }}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="md"
|
||||||
|
py="xs"
|
||||||
|
>
|
||||||
|
<Box style={{ position: 'relative', width: 140, height: 140 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
innerRadius={46}
|
||||||
|
outerRadius={65}
|
||||||
|
paddingAngle={nonZeroSlices.length > 1 ? 3 : 0}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
textAlign: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fw={700} fz={22} lh={1.1} c={isDark ? 'white' : 'dark'}>
|
||||||
|
{totalApplications}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('dashboard.charts.total')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack gap={10} style={{ flex: 1, width: '100%' }}>
|
||||||
|
{statusDistribution.map((item) => {
|
||||||
|
const pct =
|
||||||
|
totalApplications > 0
|
||||||
|
? Math.round((item.value / totalApplications) * 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<Group key={item.name} justify="space-between" wrap="nowrap" gap="xs">
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<ColorSwatch size={10} color={item.color} />
|
||||||
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" style={{ minWidth: 32, textAlign: 'right' }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAward,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconFolder,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export interface DashboardStatsProps {
|
||||||
|
activeLicenses: number;
|
||||||
|
pendingApplications: number;
|
||||||
|
approvedApplications: number;
|
||||||
|
documentsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardStats({
|
||||||
|
activeLicenses,
|
||||||
|
pendingApplications,
|
||||||
|
approvedApplications,
|
||||||
|
documentsCount,
|
||||||
|
}: DashboardStatsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: t('dashboard.stats.activeLicenses'),
|
||||||
|
value: activeLicenses,
|
||||||
|
trend: activeLicenses > 0 ? t('dashboard.stats.allInGoodStanding') : '—',
|
||||||
|
icon: IconAward,
|
||||||
|
color: 'teal',
|
||||||
|
trendColor: 'teal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('dashboard.stats.pendingApplications'),
|
||||||
|
value: pendingApplications,
|
||||||
|
trend: pendingApplications > 0 ? t('dashboard.stats.inReview') : '0 pending',
|
||||||
|
icon: IconClockHour4,
|
||||||
|
color: 'orange',
|
||||||
|
trendColor: 'orange',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('dashboard.stats.approved'),
|
||||||
|
value: approvedApplications,
|
||||||
|
trend: approvedApplications > 0 ? t('dashboard.stats.passRate') : '—',
|
||||||
|
icon: IconCircleCheck,
|
||||||
|
color: 'green',
|
||||||
|
trendColor: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('dashboard.stats.documents'),
|
||||||
|
value: documentsCount,
|
||||||
|
trend: documentsCount > 0 ? t('dashboard.stats.upToDate') : '—',
|
||||||
|
icon: IconFolder,
|
||||||
|
color: 'emaPrimary',
|
||||||
|
trendColor: 'emaPrimary',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const IconComponent = stat.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={stat.title}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="lg"
|
||||||
|
style={{
|
||||||
|
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: '0.5px' }}>
|
||||||
|
{stat.title}
|
||||||
|
</Text>
|
||||||
|
<Text fz={32} fw={700} lh={1.1} style={{ letterSpacing: '-0.5px' }}>
|
||||||
|
{stat.value}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" fw={600} c={stat.trendColor}>
|
||||||
|
{stat.trend}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<ThemeIcon
|
||||||
|
size={48}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={stat.color}
|
||||||
|
>
|
||||||
|
<IconComponent size={24} stroke={1.8} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAward,
|
||||||
|
IconCertificate,
|
||||||
|
IconClockHour4,
|
||||||
|
IconFileText,
|
||||||
|
IconPrinter,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconX,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||||
|
import { localized, STATUS_COLORS, STATUS_LABELS } from '@ema-platform/api';
|
||||||
|
|
||||||
|
export interface ExportStatementModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
displayName: string;
|
||||||
|
applications: LicenseApplication[];
|
||||||
|
licenses: IssuedLicense[];
|
||||||
|
documentsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportStatementModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
displayName,
|
||||||
|
applications,
|
||||||
|
licenses,
|
||||||
|
documentsCount,
|
||||||
|
}: ExportStatementModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
|
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE');
|
||||||
|
const inProgressApps = applications.filter((a) => a.status !== 'DRAFT');
|
||||||
|
const todayStr = new Date().toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePrint() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
padding="xl"
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" radius="md">
|
||||||
|
<IconCertificate size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
{t('dashboard.export.title')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="lg" id="ema-export-statement-content">
|
||||||
|
{/* Statement Header */}
|
||||||
|
<Paper
|
||||||
|
p="md"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark
|
||||||
|
? 'var(--mantine-color-dark-6)'
|
||||||
|
: 'var(--mantine-color-gray-0)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
|
||||||
|
{t('app.authority', 'Ethiopian Maritime Authority')}
|
||||||
|
</Text>
|
||||||
|
<Text fz={18} fw={700} mt={2}>
|
||||||
|
{displayName || t('dashboard.export.accountHolder')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('dashboard.export.subtitle')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ textAlign: 'right' }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.export.issueDate')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{todayStr}
|
||||||
|
</Text>
|
||||||
|
<Badge variant="outline" color="teal" size="xs" mt={4}>
|
||||||
|
Electronic Copy
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Snapshot Summary Metrics */}
|
||||||
|
<SimpleGrid cols={{ base: 3 }} spacing="sm">
|
||||||
|
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.export.activeLicencesCount')}
|
||||||
|
</Text>
|
||||||
|
<Text fz={24} fw={700} c="teal">
|
||||||
|
{activeLicenses.length}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.export.pendingApplicationsCount')}
|
||||||
|
</Text>
|
||||||
|
<Text fz={24} fw={700} c="orange">
|
||||||
|
{inProgressApps.length}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.export.verifiedDocumentsCount')}
|
||||||
|
</Text>
|
||||||
|
<Text fz={24} fw={700} c="blue">
|
||||||
|
{documentsCount}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Active Licences Table */}
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} size="sm" mb="xs">
|
||||||
|
{t('dashboard.sections.myLicences.title')} ({activeLicenses.length})
|
||||||
|
</Text>
|
||||||
|
{activeLicenses.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('applications.licences.empty')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Certificate #</Table.Th>
|
||||||
|
<Table.Th>Licence Type</Table.Th>
|
||||||
|
<Table.Th>Expiry Date</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{activeLicenses.map((lic) => (
|
||||||
|
<Table.Tr key={lic.id}>
|
||||||
|
<Table.Td fw={600}>{lic.certificateNumber}</Table.Td>
|
||||||
|
<Table.Td>{localized(lic.licenseType?.name) || '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{lic.expiryDate
|
||||||
|
? new Date(lic.expiryDate).toLocaleDateString('en-GB')
|
||||||
|
: '—'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="teal" variant="light">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Recent Applications Table */}
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} size="sm" mb="xs">
|
||||||
|
{t('dashboard.sections.myApplications.title')} ({applications.slice(0, 5).length})
|
||||||
|
</Text>
|
||||||
|
{applications.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('dashboard.sections.myApplications.empty')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Application #</Table.Th>
|
||||||
|
<Table.Th>Licence Type</Table.Th>
|
||||||
|
<Table.Th>Submitted</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{applications.slice(0, 5).map((app) => (
|
||||||
|
<Table.Tr key={app.id}>
|
||||||
|
<Table.Td fw={600}>{app.applicationNumber}</Table.Td>
|
||||||
|
<Table.Td>{localized(app.licenseType?.name) || '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{app.createdAt
|
||||||
|
? new Date(app.createdAt).toLocaleDateString('en-GB')
|
||||||
|
: '—'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color={STATUS_COLORS[app.status] ?? 'gray'}
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[app.status] ?? app.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Text size="11px" c="dimmed" ta="center">
|
||||||
|
{t('dashboard.export.officialFooter')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Modal Actions */}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t('dashboard.export.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="emaPrimary"
|
||||||
|
leftSection={<IconPrinter size={16} />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
{t('dashboard.export.printButton')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowRight,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleDot,
|
||||||
|
IconFolder,
|
||||||
|
IconRocket,
|
||||||
|
IconSparkles,
|
||||||
|
IconUserCheck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export interface OnboardingChecklistCardProps {
|
||||||
|
displayName: string;
|
||||||
|
documentsCount: number;
|
||||||
|
onBrowseCatalogue: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingChecklistCard({
|
||||||
|
displayName,
|
||||||
|
documentsCount,
|
||||||
|
onBrowseCatalogue,
|
||||||
|
}: OnboardingChecklistCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
|
const step1Complete = Boolean(displayName);
|
||||||
|
const step2Complete = documentsCount > 0;
|
||||||
|
const step3Complete = false;
|
||||||
|
|
||||||
|
const completedCount =
|
||||||
|
(step1Complete ? 1 : 0) + (step2Complete ? 1 : 0) + (step3Complete ? 1 : 0);
|
||||||
|
const progressPct = Math.round((completedCount / 3) * 100);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: t('dashboard.onboarding.step1'),
|
||||||
|
desc: t('dashboard.onboarding.step1Desc'),
|
||||||
|
icon: IconUserCheck,
|
||||||
|
completed: step1Complete,
|
||||||
|
action: !step1Complete ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="emaPrimary"
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
>
|
||||||
|
{t('common.continue')}
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: t('dashboard.onboarding.step2'),
|
||||||
|
desc: t('dashboard.onboarding.step2Desc'),
|
||||||
|
icon: IconFolder,
|
||||||
|
completed: step2Complete,
|
||||||
|
action: !step2Complete ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
onClick={() => navigate('/documents')}
|
||||||
|
rightSection={<IconArrowRight size={14} />}
|
||||||
|
>
|
||||||
|
{t('dashboard.onboarding.viewVault')}
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: t('dashboard.onboarding.step3'),
|
||||||
|
desc: t('dashboard.onboarding.step3Desc'),
|
||||||
|
icon: IconRocket,
|
||||||
|
completed: step3Complete,
|
||||||
|
action: (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="filled"
|
||||||
|
color="emaPrimary"
|
||||||
|
onClick={onBrowseCatalogue}
|
||||||
|
rightSection={<IconArrowRight size={14} />}
|
||||||
|
>
|
||||||
|
{t('dashboard.onboarding.browseCatalogue')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark
|
||||||
|
? 'var(--mantine-color-dark-7, #1a1b1e)'
|
||||||
|
: 'var(--mantine-color-body, #ffffff)',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 4px 20px rgba(0, 0, 0, 0.3)'
|
||||||
|
: '0 4px 16px rgba(42, 89, 172, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap" gap="sm">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon
|
||||||
|
size={40}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="emaPrimary"
|
||||||
|
>
|
||||||
|
<IconSparkles size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Title order={4} fw={700}>
|
||||||
|
{t('dashboard.onboarding.title')}
|
||||||
|
</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('dashboard.onboarding.subtitle')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={completedCount === 3 ? 'green' : 'blue'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{t('dashboard.onboarding.progress', {
|
||||||
|
completed: completedCount,
|
||||||
|
total: 3,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={progressPct}
|
||||||
|
color={completedCount === 3 ? 'green' : 'emaPrimary'}
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
mb="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
{steps.map((step) => {
|
||||||
|
const StepIcon = step.icon;
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={step.id}
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark
|
||||||
|
? 'var(--mantine-color-dark-6, #25262b)'
|
||||||
|
: step.completed
|
||||||
|
? 'var(--mantine-color-gray-0, #f8f9fa)'
|
||||||
|
: '#ffffff',
|
||||||
|
borderColor: step.completed
|
||||||
|
? 'transparent'
|
||||||
|
: isDark
|
||||||
|
? 'var(--mantine-color-dark-4)'
|
||||||
|
: 'var(--mantine-color-gray-3)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
size={36}
|
||||||
|
radius="xl"
|
||||||
|
variant={step.completed ? 'filled' : 'light'}
|
||||||
|
color={step.completed ? 'teal' : 'gray'}
|
||||||
|
>
|
||||||
|
{step.completed ? (
|
||||||
|
<IconCheck size={18} stroke={2.5} />
|
||||||
|
) : (
|
||||||
|
<StepIcon size={18} />
|
||||||
|
)}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
style={{
|
||||||
|
textDecoration: step.completed ? 'line-through' : 'none',
|
||||||
|
opacity: step.completed ? 0.75 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</Text>
|
||||||
|
{step.completed && (
|
||||||
|
<Badge size="xs" variant="dot" color="teal">
|
||||||
|
{t('dashboard.onboarding.completed')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{step.desc}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{step.action && <Box>{step.action}</Box>}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Title({ order, fw, children }: { order: 1 | 2 | 3 | 4 | 5 | 6; fw: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Text fz={order === 4 ? 16 : 18} fw={fw} lh={1.2}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
UnstyledButton,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconBell,
|
||||||
|
IconChevronRight,
|
||||||
|
IconHeadset,
|
||||||
|
IconLifebuoy,
|
||||||
|
IconMail,
|
||||||
|
IconMapPin,
|
||||||
|
IconPhone,
|
||||||
|
IconSquarePlus,
|
||||||
|
IconUpload,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export interface QuickActionsCardProps {
|
||||||
|
onApplyClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActionsCard({ onApplyClick }: QuickActionsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [supportOpened, setSupportOpened] = useState(false);
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
key: 'apply',
|
||||||
|
title: t('dashboard.quickActionsList.applyLicense'),
|
||||||
|
description: t('dashboard.quickActionsList.applyLicenseDesc'),
|
||||||
|
icon: IconSquarePlus,
|
||||||
|
color: 'emaPrimary',
|
||||||
|
onClick: () => {
|
||||||
|
if (onApplyClick) {
|
||||||
|
onApplyClick();
|
||||||
|
} else {
|
||||||
|
const el = document.getElementById('license-catalogue-section');
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
navigate('/licensing/applications');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upload',
|
||||||
|
title: t('dashboard.quickActionsList.uploadDocument'),
|
||||||
|
description: t('dashboard.quickActionsList.uploadDocumentDesc'),
|
||||||
|
icon: IconUpload,
|
||||||
|
color: 'teal',
|
||||||
|
onClick: () => navigate('/documents'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
title: t('dashboard.quickActionsList.notifications'),
|
||||||
|
description: t('dashboard.quickActionsList.notificationsDesc'),
|
||||||
|
icon: IconBell,
|
||||||
|
color: 'indigo',
|
||||||
|
onClick: () => navigate('/notifications'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'support',
|
||||||
|
title: t('dashboard.quickActionsList.support'),
|
||||||
|
description: t('dashboard.quickActionsList.supportDesc'),
|
||||||
|
icon: IconLifebuoy,
|
||||||
|
color: 'orange',
|
||||||
|
onClick: () => setSupportOpened(true),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
{t('dashboard.quickActions')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
{actions.map((item) => {
|
||||||
|
const IconComp = item.icon;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={item.key}
|
||||||
|
onClick={item.onClick}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: isDark
|
||||||
|
? 'var(--mantine-color-dark-6)'
|
||||||
|
: 'var(--mantine-color-gray-0)',
|
||||||
|
transition: 'background-color 0.15s ease, transform 0.1s ease',
|
||||||
|
border: isDark
|
||||||
|
? '1px solid var(--mantine-color-dark-4)'
|
||||||
|
: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
size={36}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={item.color}
|
||||||
|
>
|
||||||
|
<IconComp size={18} stroke={1.8} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ textAlign: 'left' }}>
|
||||||
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.3}>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<IconChevronRight
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-dimmed, #868e96)"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={supportOpened}
|
||||||
|
onClose={() => setSupportOpened(false)}
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" radius="md">
|
||||||
|
<IconHeadset size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700}>{t('dashboard.supportModal.title')}</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('dashboard.supportModal.description')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="blue" radius="md">
|
||||||
|
<IconMail size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.supportModal.emailLabel')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t('dashboard.supportModal.email')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="teal" radius="md">
|
||||||
|
<IconPhone size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.supportModal.phoneLabel')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t('dashboard.supportModal.phone')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('dashboard.supportModal.hours')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="orange" radius="md">
|
||||||
|
<IconMapPin size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{t('dashboard.supportModal.officeLabel')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{t('dashboard.supportModal.office')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => setSupportOpened(false)}
|
||||||
|
>
|
||||||
|
{t('dashboard.supportModal.close')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Badge, Progress, Text } from '@mantine/core';
|
import { Badge, Box, Progress, Stack, Text } from '@mantine/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,20 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import type { LicenseApplication } from '@ema-platform/api';
|
import type { LicenseApplication } from '@ema-platform/api';
|
||||||
|
|
||||||
|
function formatSubmittedDate(dateStr?: string | null): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function dashboardApplicationColumns(
|
export function dashboardApplicationColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
): AdvancedColumn<LicenseApplication>[] {
|
): AdvancedColumn<LicenseApplication>[] {
|
||||||
@@ -16,40 +30,60 @@ export function dashboardApplicationColumns(
|
|||||||
{
|
{
|
||||||
header: t('dashboard.table.application'),
|
header: t('dashboard.table.application'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<>
|
<Box>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
{row.original.applicationNumber}
|
{row.original.applicationNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
{row.original.companyName ?? '—'}
|
{row.original.companyName ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('applications.table.licence'),
|
header: t('dashboard.table.type'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
{localized(row.original.licenseType?.name) || '—'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('dashboard.table.submitted'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="xs" c="dimmed" fw={500}>
|
||||||
|
{formatSubmittedDate(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('common.status'),
|
header: t('common.status'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="light" color={STATUS_COLORS[row.original.status]}>
|
<Badge
|
||||||
{STATUS_LABELS[row.original.status]}
|
variant="light"
|
||||||
|
color={STATUS_COLORS[row.original.status] ?? 'gray'}
|
||||||
|
radius="sm"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[row.original.status] ?? row.original.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('applications.table.progress'),
|
header: t('applications.table.progress'),
|
||||||
size: 180,
|
size: 140,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Progress
|
<Stack gap={4}>
|
||||||
value={STATUS_PROGRESS[row.original.status]}
|
<Progress
|
||||||
color={STATUS_COLORS[row.original.status]}
|
value={STATUS_PROGRESS[row.original.status] ?? 30}
|
||||||
size="sm"
|
color={STATUS_COLORS[row.original.status] ?? 'blue'}
|
||||||
radius="xl"
|
size="xs"
|
||||||
/>
|
radius="xl"
|
||||||
|
/>
|
||||||
|
<Text size="10px" c="dimmed" ta="right">
|
||||||
|
{STATUS_PROGRESS[row.original.status] ?? 30}%
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -11,22 +11,28 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
useComputedColorScheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconCertificate,
|
IconArrowRight,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconShieldCheck,
|
IconHelpCircle,
|
||||||
|
IconPlus,
|
||||||
|
IconPrinter,
|
||||||
|
IconShip,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge';
|
import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge';
|
||||||
import {
|
import {
|
||||||
@@ -35,29 +41,33 @@ import {
|
|||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
|
useGetMyDashboardSummaryQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
|
useGetMyPersonalDocumentsQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||||
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
|
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
|
||||||
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
|
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
|
||||||
import { dashboardApplicationColumns } from './columns';
|
import { dashboardApplicationColumns } from './columns';
|
||||||
|
import { DashboardStats } from './DashboardStats';
|
||||||
|
import {
|
||||||
|
DashboardCharts,
|
||||||
|
StatusDonutCard,
|
||||||
|
type MonthlyTrendItem,
|
||||||
|
type StatusDistributionItem,
|
||||||
|
} from './DashboardCharts';
|
||||||
|
import { QuickActionsCard } from './QuickActionsCard';
|
||||||
|
import { OnboardingChecklistCard } from './OnboardingChecklistCard';
|
||||||
|
import { ExportStatementModal } from './ExportStatementModal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The applicant's home screen.
|
* Days before expiry at which a licence is worth flagging when its type
|
||||||
*
|
* carries no renewal window of its own. The window itself is configured per
|
||||||
* Ordered by what the applicant needs from it: first anything blocked on them,
|
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
|
||||||
* then the records they already have — licences, then applications — and only
|
* what the API's `renewable` flag is computed from, so it is read off the
|
||||||
* then the catalogue to file something new. A returning applicant comes here to
|
* licence rather than assumed here.
|
||||||
* check on their own things, not to shop; the catalogue used to sit above both
|
|
||||||
* and pushed them below the fold.
|
|
||||||
*
|
|
||||||
* The exception is an applicant with nothing at all, for whom both sections are
|
|
||||||
* empty and the catalogue *is* the page. Every figure is the signed-in user's
|
|
||||||
* own data — there are no illustrative numbers on this page.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Days before expiry at which a licence is worth flagging. */
|
|
||||||
const EXPIRY_WARNING_DAYS = 60;
|
const EXPIRY_WARNING_DAYS = 60;
|
||||||
|
|
||||||
function daysUntil(date: string): number {
|
function daysUntil(date: string): number {
|
||||||
@@ -79,6 +89,9 @@ function formatMoney(
|
|||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [appFilter, setAppFilter] = useState<'ALL' | 'IN_REVIEW' | 'APPROVED' | 'ACTION'>('ALL');
|
||||||
|
const [exportOpened, setExportOpened] = useState(false);
|
||||||
|
|
||||||
const displayName = useSelector(
|
const displayName = useSelector(
|
||||||
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
|
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
|
||||||
state.auth.user?.name?.en || state.auth.user?.username || '',
|
state.auth.user?.name?.en || state.auth.user?.username || '',
|
||||||
@@ -86,8 +99,9 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery();
|
const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery();
|
||||||
const { data: licenses } = useGetMyLicensesQuery();
|
const { data: licenses } = useGetMyLicensesQuery();
|
||||||
const [getCertificateUrl, { isLoading: isDownloading }] =
|
const { data: personalDocs } = useGetMyPersonalDocumentsQuery();
|
||||||
useGetCertificateUrlMutation();
|
const { data: summaryData } = useGetMyDashboardSummaryQuery();
|
||||||
|
const [getCertificateUrl, { isLoading: isDownloading }] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
|
|
||||||
const items = useMemo(() => applications?.items ?? [], [applications]);
|
const items = useMemo(() => applications?.items ?? [], [applications]);
|
||||||
@@ -100,19 +114,118 @@ export function DashboardPage() {
|
|||||||
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
|
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
|
||||||
);
|
);
|
||||||
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
|
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
|
||||||
// Nothing filed and nothing held: the two "my …" sections would both be
|
|
||||||
// empty, so they collapse into one panel and the catalogue carries the page.
|
|
||||||
const hasNoRecords = items.length === 0 && heldLicenses.length === 0;
|
|
||||||
const expiringSoon = activeLicenses.filter((l) => {
|
const expiringSoon = activeLicenses.filter((l) => {
|
||||||
const days = daysUntil(l.expiryDate);
|
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
|
||||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
|
||||||
|
return days >= 0 && days <= window;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const documentsCount = useMemo(() => {
|
||||||
|
if (!personalDocs?.slots) return 0;
|
||||||
|
return personalDocs.slots.reduce(
|
||||||
|
(sum, slot) => sum + (slot.files?.length ? slot.files.length : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}, [personalDocs]);
|
||||||
|
|
||||||
|
const approvedApplications = useMemo(() => {
|
||||||
|
const appApproved = items.filter((a) =>
|
||||||
|
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
|
||||||
|
).length;
|
||||||
|
return Math.max(appApproved, activeLicenses.length);
|
||||||
|
}, [items, activeLicenses]);
|
||||||
|
|
||||||
|
// Status distribution for donut chart
|
||||||
|
const statusDistribution = useMemo<StatusDistributionItem[]>(() => {
|
||||||
|
if (summaryData?.statusDistribution?.length) {
|
||||||
|
return summaryData.statusDistribution;
|
||||||
|
}
|
||||||
|
const approved = approvedApplications;
|
||||||
|
const inReview = inProgress.length;
|
||||||
|
const draftAndAction = needsMe.length + items.filter((a) => a.status === 'DRAFT').length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: t('dashboard.charts.approved'), value: approved, color: '#12b886' },
|
||||||
|
{ name: t('dashboard.charts.underReview'), value: inReview, color: '#2a59ac' },
|
||||||
|
{ name: t('dashboard.charts.draftPending'), value: draftAndAction, color: '#fab005' },
|
||||||
|
];
|
||||||
|
}, [summaryData, approvedApplications, inProgress.length, needsMe.length, items, t]);
|
||||||
|
|
||||||
|
// Monthly timeline trend
|
||||||
|
const monthlyTrend = useMemo<MonthlyTrendItem[]>(() => {
|
||||||
|
if (summaryData?.monthlyTrend?.length) {
|
||||||
|
return summaryData.monthlyTrend;
|
||||||
|
}
|
||||||
|
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const now = new Date();
|
||||||
|
const trend: MonthlyTrendItem[] = [];
|
||||||
|
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const mIdx = d.getMonth();
|
||||||
|
const yr = d.getFullYear();
|
||||||
|
const matches = items.filter((app) => {
|
||||||
|
if (!app.createdAt) return false;
|
||||||
|
const appD = new Date(app.createdAt);
|
||||||
|
return appD.getFullYear() === yr && appD.getMonth() === mIdx;
|
||||||
|
});
|
||||||
|
const submitted = matches.filter((a) => a.status !== 'DRAFT').length;
|
||||||
|
const approved = matches.filter((a) =>
|
||||||
|
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
trend.push({
|
||||||
|
month: monthNames[mIdx],
|
||||||
|
year: yr,
|
||||||
|
submitted,
|
||||||
|
approved,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return trend;
|
||||||
|
}, [summaryData, items]);
|
||||||
|
|
||||||
|
const categoryBreakdown = useMemo(() => {
|
||||||
|
if (summaryData?.categoryBreakdown?.length) {
|
||||||
|
return summaryData.categoryBreakdown;
|
||||||
|
}
|
||||||
|
const catMap: Record<string, number> = {};
|
||||||
|
items.forEach((app) => {
|
||||||
|
const cat = app.licenseType?.category || 'GENERAL';
|
||||||
|
const label = cat.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
catMap[label] = (catMap[label] || 0) + 1;
|
||||||
|
});
|
||||||
|
return Object.entries(catMap).map(([category, count]) => ({ category, count }));
|
||||||
|
}, [summaryData, items]);
|
||||||
|
|
||||||
|
// Filtered applications for recent table
|
||||||
|
const filteredApplications = useMemo(() => {
|
||||||
|
switch (appFilter) {
|
||||||
|
case 'IN_REVIEW':
|
||||||
|
return inProgress;
|
||||||
|
case 'APPROVED':
|
||||||
|
return items.filter((a) =>
|
||||||
|
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
|
||||||
|
);
|
||||||
|
case 'ACTION':
|
||||||
|
return needsMe;
|
||||||
|
case 'ALL':
|
||||||
|
default:
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}, [items, inProgress, needsMe, appFilter]);
|
||||||
|
|
||||||
async function downloadCertificate(license: IssuedLicense) {
|
async function downloadCertificate(license: IssuedLicense) {
|
||||||
const result = await getCertificateUrl(license.id).unwrap();
|
const result = await getCertificateUrl(license.id).unwrap();
|
||||||
window.open(result.url, '_blank', 'noopener');
|
window.open(result.url, '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToCatalogue() {
|
||||||
|
const el = document.getElementById('license-catalogue-section');
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageLoader label={t('dashboard.loading')} height={450} />;
|
return <PageLoader label={t('dashboard.loading')} height={450} />;
|
||||||
}
|
}
|
||||||
@@ -120,19 +233,24 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<Container size="xl" py="lg">
|
<Container size="xl" py="lg">
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
{/* Coastal Modern Hero Section */}
|
||||||
<Hero
|
<Hero
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
applicationCount={items.length}
|
applicationCount={items.length}
|
||||||
licenseCount={activeLicenses.length}
|
licenseCount={activeLicenses.length}
|
||||||
|
onApplyClick={scrollToCatalogue}
|
||||||
|
onExportClick={() => setExportOpened(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
|
{/* Profile Completion Nudge */}
|
||||||
<ProfileCompletionNudge />
|
<ProfileCompletionNudge />
|
||||||
|
|
||||||
|
{/* Priority Action Required */}
|
||||||
{needsMe.length > 0 && (
|
{needsMe.length > 0 && (
|
||||||
<ActionRequired applications={needsMe} navigate={navigate} />
|
<ActionRequired applications={needsMe} navigate={navigate} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Expiring Soon Notice */}
|
||||||
{expiringSoon.length > 0 && (
|
{expiringSoon.length > 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -154,85 +272,206 @@ export function DashboardPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StatRow
|
{/* KPI Stat Cards */}
|
||||||
inProgress={inProgress.length}
|
<DashboardStats
|
||||||
needsMe={needsMe.length}
|
|
||||||
activeLicenses={activeLicenses.length}
|
activeLicenses={activeLicenses.length}
|
||||||
expiringSoon={expiringSoon.length}
|
pendingApplications={inProgress.length}
|
||||||
|
approvedApplications={approvedApplications}
|
||||||
|
documentsCount={documentsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasNoRecords ? (
|
{/* If user is brand new with 0 applications & 0 licenses, show OnboardingChecklistCard */}
|
||||||
<GetStartedPanel />
|
{items.length === 0 && activeLicenses.length === 0 ? (
|
||||||
|
<OnboardingChecklistCard
|
||||||
|
displayName={displayName}
|
||||||
|
documentsCount={documentsCount}
|
||||||
|
onBrowseCatalogue={scrollToCatalogue}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Grid gutter="md">
|
||||||
<Section title={t('dashboard.sections.myLicences.title')}>
|
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||||
{heldLicenses.length === 0 ? (
|
<DashboardCharts
|
||||||
<EmptyCard message={t('applications.licences.empty')} />
|
statusDistribution={statusDistribution}
|
||||||
) : (
|
monthlyTrend={monthlyTrend}
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
categoryBreakdown={categoryBreakdown}
|
||||||
{heldLicenses.map((license) => (
|
totalApplications={items.length}
|
||||||
<LicenseCard
|
/>
|
||||||
key={license.id}
|
</Grid.Col>
|
||||||
license={license}
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
isDownloading={isDownloading}
|
<StatusDonutCard
|
||||||
isRenewing={isRenewing}
|
statusDistribution={statusDistribution}
|
||||||
onDownload={() => downloadCertificate(license)}
|
totalApplications={items.length}
|
||||||
onRenew={() => renewLicense(license)}
|
/>
|
||||||
/>
|
</Grid.Col>
|
||||||
))}
|
</Grid>
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
title={t('dashboard.sections.myApplications.title')}
|
|
||||||
action={
|
|
||||||
items.length > 0 ? (
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/licensing/applications')}
|
|
||||||
>
|
|
||||||
{t('common.viewAll')}
|
|
||||||
</Anchor>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
|
|
||||||
) : (
|
|
||||||
<ApplicationTable
|
|
||||||
applications={items.slice(0, 6)}
|
|
||||||
navigate={navigate}
|
|
||||||
onRefresh={refetch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Section
|
{/* Two-Column Section: Left (Applications & Licenses), Right (Actions & Support) */}
|
||||||
title={t('dashboard.sections.apply.title')}
|
<Grid gutter="xl">
|
||||||
description={t('dashboard.sections.apply.description')}
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
>
|
<Stack gap="xl">
|
||||||
<LicenseCatalogue />
|
{/* Recent Applications Card */}
|
||||||
</Section>
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" align="center" mb="md" wrap="wrap" gap="sm">
|
||||||
|
<Box>
|
||||||
|
<Title order={4}>{t('dashboard.sections.myApplications.title')}</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('applications.subtitle')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group gap="sm">
|
||||||
|
{items.length > 0 && (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
value={appFilter}
|
||||||
|
onChange={(val) => setAppFilter(val as 'ALL' | 'IN_REVIEW' | 'APPROVED' | 'ACTION')}
|
||||||
|
data={[
|
||||||
|
{ label: t('dashboard.table.all'), value: 'ALL' },
|
||||||
|
{ label: t('dashboard.table.inReview'), value: 'IN_REVIEW' },
|
||||||
|
{ label: t('dashboard.table.approved'), value: 'APPROVED' },
|
||||||
|
{ label: t('dashboard.table.actionRequired'), value: 'ACTION' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
onClick={() => navigate('/licensing/applications')}
|
||||||
|
>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{t('common.viewAll')}
|
||||||
|
<IconArrowRight size={14} />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{filteredApplications.length === 0 ? (
|
||||||
|
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
|
||||||
|
) : (
|
||||||
|
<ApplicationTable
|
||||||
|
applications={filteredApplications.slice(0, 6)}
|
||||||
|
navigate={navigate}
|
||||||
|
onRefresh={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* My Licences Section */}
|
||||||
|
<Section
|
||||||
|
title={t('dashboard.sections.myLicences.title')}
|
||||||
|
action={
|
||||||
|
heldLicenses.length > 0 ? (
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
onClick={() => navigate('/licensing/applications')}
|
||||||
|
>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{t('common.viewAll')}
|
||||||
|
<IconArrowRight size={14} />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{heldLicenses.length === 0 ? (
|
||||||
|
<EmptyCard message={t('applications.licences.empty')} />
|
||||||
|
) : (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
{heldLicenses.map((license) => (
|
||||||
|
<LicenseCard
|
||||||
|
key={license.id}
|
||||||
|
license={license}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
isRenewing={isRenewing}
|
||||||
|
onDownload={() => downloadCertificate(license)}
|
||||||
|
onRenew={() => renewLicense(license)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Right Column: Quick Actions & Help */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<QuickActionsCard onApplyClick={scrollToCatalogue} />
|
||||||
|
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group gap="xs" mb="xs">
|
||||||
|
<ThemeIcon variant="light" color="emaTeal" size="md" radius="md">
|
||||||
|
<IconHelpCircle size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
{t('dashboard.supportModal.title')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.5} mb="sm">
|
||||||
|
{t('dashboard.supportModal.description')}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="emaPrimary"
|
||||||
|
size="xs"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/licensing/applications')}
|
||||||
|
>
|
||||||
|
{t('common.learnMore')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* License Catalogue Section */}
|
||||||
|
<Box id="license-catalogue-section" pt="md">
|
||||||
|
<Section
|
||||||
|
title={t('dashboard.sections.apply.title')}
|
||||||
|
description={t('dashboard.sections.apply.description')}
|
||||||
|
>
|
||||||
|
<LicenseCatalogue />
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Statement Export Modal */}
|
||||||
|
<ExportStatementModal
|
||||||
|
opened={exportOpened}
|
||||||
|
onClose={() => setExportOpened(false)}
|
||||||
|
displayName={displayName}
|
||||||
|
applications={items}
|
||||||
|
licenses={heldLicenses}
|
||||||
|
documentsCount={documentsCount}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- components
|
// --------------------------------------------------------------- sub-components
|
||||||
|
|
||||||
function Hero({
|
function Hero({
|
||||||
displayName,
|
displayName,
|
||||||
applicationCount,
|
applicationCount,
|
||||||
licenseCount,
|
licenseCount,
|
||||||
|
onApplyClick,
|
||||||
|
onExportClick,
|
||||||
}: {
|
}: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
applicationCount: number;
|
applicationCount: number;
|
||||||
licenseCount: number;
|
licenseCount: number;
|
||||||
|
onApplyClick: () => void;
|
||||||
|
onExportClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
const summary =
|
const summary =
|
||||||
applicationCount === 0 && licenseCount === 0
|
applicationCount === 0 && licenseCount === 0
|
||||||
? t('dashboard.hero.summaryEmpty')
|
? t('dashboard.hero.summaryEmpty')
|
||||||
@@ -244,34 +483,80 @@ function Hero({
|
|||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
radius="lg"
|
radius="lg"
|
||||||
p="xl"
|
p={{ base: 'lg', md: 'xl' }}
|
||||||
style={{
|
style={{
|
||||||
background:
|
background: isDark
|
||||||
'linear-gradient(135deg, var(--mantine-color-emaPrimary-7) 0%, var(--mantine-color-emaPrimary-9) 55%, var(--mantine-color-emaTeal-8) 100%)',
|
? 'linear-gradient(135deg, #0e2042 0%, #153261 45%, #0d463b 100%)'
|
||||||
|
: 'linear-gradient(135deg, #18438b 0%, #2054a8 45%, #138f77 100%)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 8px 24px rgba(0, 0, 0, 0.45)'
|
||||||
|
: '0 8px 24px rgba(32, 84, 168, 0.25)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Box>
|
<Box style={{ zIndex: 2, maxWidth: 620 }}>
|
||||||
<Text size="sm" style={{ opacity: 0.85 }}>
|
<Text size="xs" fw={600} tt="uppercase" style={{ opacity: 0.85, letterSpacing: '0.5px' }}>
|
||||||
{t('app.authority')}
|
{t('app.authority', 'Ethiopian Maritime Authority')}
|
||||||
</Text>
|
</Text>
|
||||||
<Title order={2} mt={4} c="white">
|
<Title order={2} mt={4} c="white" fw={700} style={{ letterSpacing: '-0.3px' }}>
|
||||||
{displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
|
{displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
|
<Text size="sm" mt="xs" style={{ opacity: 0.9, lineHeight: 1.55 }}>
|
||||||
{summary}
|
{summary}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Group gap="sm" mt="md" wrap="wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="white"
|
||||||
|
variant="white"
|
||||||
|
c="emaPrimary.8"
|
||||||
|
leftSection={<IconPlus size={16} stroke={2.5} />}
|
||||||
|
onClick={onApplyClick}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{t('dashboard.quickActionsList.applyLicense')}
|
||||||
|
</Button>
|
||||||
|
{onExportClick && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
color="white"
|
||||||
|
leftSection={<IconPrinter size={16} />}
|
||||||
|
onClick={onExportClick}
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.45)',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('dashboard.export.button')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
<ThemeIcon
|
|
||||||
size={56}
|
<Box
|
||||||
radius="md"
|
|
||||||
variant="transparent"
|
|
||||||
c="white"
|
|
||||||
visibleFrom="sm"
|
visibleFrom="sm"
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: isDark
|
||||||
|
? 'rgba(255, 255, 255, 0.08)'
|
||||||
|
: 'rgba(255, 255, 255, 0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IconShieldCheck size={44} stroke={1.3} />
|
<IconShip size={60} stroke={1.3} color="white" />
|
||||||
</ThemeIcon>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
@@ -285,12 +570,19 @@ function ActionRequired({
|
|||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = computedColorScheme === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
padding="md"
|
padding="md"
|
||||||
style={{ backgroundColor: 'var(--mantine-color-orange-light)' }}
|
style={{
|
||||||
|
backgroundColor: isDark
|
||||||
|
? 'rgba(247, 103, 7, 0.12)'
|
||||||
|
: 'var(--mantine-color-orange-light)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="xs" mb="sm">
|
<Group gap="xs" mb="sm">
|
||||||
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
||||||
@@ -304,7 +596,13 @@ function ActionRequired({
|
|||||||
{applications.map((app) => {
|
{applications.map((app) => {
|
||||||
const detail = detailFor(app, t);
|
const detail = detailFor(app, t);
|
||||||
return (
|
return (
|
||||||
<Paper key={app.id} radius="sm" p="sm" withBorder>
|
<Paper
|
||||||
|
key={app.id}
|
||||||
|
radius="sm"
|
||||||
|
p="sm"
|
||||||
|
withBorder
|
||||||
|
style={{ backgroundColor: 'var(--mantine-color-body)' }}
|
||||||
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
@@ -343,7 +641,6 @@ function ActionRequired({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the applicant has to do next, and where that happens. */
|
|
||||||
function detailFor(
|
function detailFor(
|
||||||
app: LicenseApplication,
|
app: LicenseApplication,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -385,48 +682,6 @@ function detailFor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatRow({
|
|
||||||
inProgress,
|
|
||||||
needsMe,
|
|
||||||
activeLicenses,
|
|
||||||
expiringSoon,
|
|
||||||
}: {
|
|
||||||
inProgress: number;
|
|
||||||
needsMe: number;
|
|
||||||
activeLicenses: number;
|
|
||||||
expiringSoon: number;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const stats = [
|
|
||||||
{ label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' },
|
|
||||||
{ label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' },
|
|
||||||
{ label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' },
|
|
||||||
{ label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
|
||||||
{stats.map((stat) => (
|
|
||||||
<Card key={stat.label} withBorder radius="md" padding="md">
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
|
||||||
{stat.label}
|
|
||||||
</Text>
|
|
||||||
<Text fz={30} fw={700} lh={1.2} mt={4}>
|
|
||||||
{stat.value}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<ThemeIcon variant="light" color={stat.color} radius="md" size="lg">
|
|
||||||
<stat.icon size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -456,8 +711,6 @@ function Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function ApplicationTable({
|
function ApplicationTable({
|
||||||
applications,
|
applications,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -492,30 +745,6 @@ function ApplicationTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The first-visit panel, in place of two empty sections saying the same thing.
|
|
||||||
* It carries no call to action of its own — the catalogue is directly beneath
|
|
||||||
* it, and a button that only scrolls the page is noise.
|
|
||||||
*/
|
|
||||||
function GetStartedPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Card withBorder radius="md" padding="xl">
|
|
||||||
<Group gap="md" wrap="nowrap" align="flex-start">
|
|
||||||
<ThemeIcon variant="light" color="emaPrimary" size={44} radius="md">
|
|
||||||
<IconCertificate size={24} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box>
|
|
||||||
<Title order={4}>{t('dashboard.getStarted.title')}</Title>
|
|
||||||
<Text size="sm" c="dimmed" mt={4} maw={620}>
|
|
||||||
{t('dashboard.getStarted.body')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyCard({ message }: { message: string }) {
|
function EmptyCard({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<Card withBorder radius="md" padding="xl">
|
<Card withBorder radius="md" padding="xl">
|
||||||
|
|||||||
@@ -490,9 +490,16 @@ export function LicenseApplicationPage() {
|
|||||||
// underneath them.
|
// underneath them.
|
||||||
const editableWhileSubmitted =
|
const editableWhileSubmitted =
|
||||||
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
||||||
|
// A draft of a type EMA has since stopped offering is frozen: the server
|
||||||
|
// refuses every edit and the submit (`assertTypeOpen`), so the form is shown
|
||||||
|
// read-only rather than letting the applicant fill it in and fail at the
|
||||||
|
// end. Anything already submitted keeps moving and is not affected.
|
||||||
|
const typeClosed =
|
||||||
|
application.status === "DRAFT" && config.licenseType.isActive === false;
|
||||||
const readOnly =
|
const readOnly =
|
||||||
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
typeClosed ||
|
||||||
!editableWhileSubmitted;
|
(!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||||
|
!editableWhileSubmitted);
|
||||||
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
||||||
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
||||||
// to the summary first.
|
// to the summary first.
|
||||||
@@ -828,6 +835,24 @@ export function LicenseApplicationPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{typeClosed && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
title={t(
|
||||||
|
"licenseApplication.typeClosedTitle",
|
||||||
|
"This service is no longer offered",
|
||||||
|
)}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"licenseApplication.typeClosed",
|
||||||
|
"EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.",
|
||||||
|
{ name: localized(config.licenseType.name) },
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{config.licenseType.requiresIssuanceScheduling &&
|
{config.licenseType.requiresIssuanceScheduling &&
|
||||||
(application.status === "PAYMENT_CONFIRMED" ||
|
(application.status === "PAYMENT_CONFIRMED" ||
|
||||||
application.status === "SCHEDULED") && (
|
application.status === "SCHEDULED") && (
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export function applicationActionsColumn(
|
|||||||
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
|
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
// The server freezes a draft of a type that has been closed, so
|
||||||
|
// there is nothing to continue into; discarding stays available.
|
||||||
|
disabled={app.status === 'DRAFT' && app.licenseType?.isActive === false}
|
||||||
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
|
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
|
||||||
variant={
|
variant={
|
||||||
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
|
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
|
||||||
@@ -116,7 +119,9 @@ export function applicationActionsColumn(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{app.status === 'DRAFT'
|
{app.status === 'DRAFT'
|
||||||
? t('applications.actions.continue')
|
? app.licenseType?.isActive === false
|
||||||
|
? t('applications.actions.closed')
|
||||||
|
: t('applications.actions.continue')
|
||||||
: app.status === 'RESUBMIT_REQUIRED'
|
: app.status === 'RESUBMIT_REQUIRED'
|
||||||
? t('applications.actions.fixResubmit')
|
? t('applications.actions.fixResubmit')
|
||||||
: app.status === 'PAYMENT_PENDING'
|
: app.status === 'PAYMENT_PENDING'
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ import { applicationColumns } from './columns';
|
|||||||
import { applicationActionsColumn } from './actions';
|
import { applicationActionsColumn } from './actions';
|
||||||
import classes from '../MyApplicationsPage.module.css';
|
import classes from '../MyApplicationsPage.module.css';
|
||||||
|
|
||||||
/** Days before expiry at which a licence is worth flagging. */
|
/**
|
||||||
|
* Days before expiry at which a licence is worth flagging when its type
|
||||||
|
* carries no renewal window of its own. The window itself is configured per
|
||||||
|
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
|
||||||
|
* what the API's `renewable` flag is computed from, so it is read off the
|
||||||
|
* licence rather than assumed here.
|
||||||
|
*/
|
||||||
const EXPIRY_WARNING_DAYS = 60;
|
const EXPIRY_WARNING_DAYS = 60;
|
||||||
|
|
||||||
function daysUntil(date: string): number {
|
function daysUntil(date: string): number {
|
||||||
@@ -240,7 +246,8 @@ export function MyApplicationsPage() {
|
|||||||
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
|
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
|
||||||
const expiringSoon = activeLicences.filter((l) => {
|
const expiringSoon = activeLicences.filter((l) => {
|
||||||
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
|
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
|
||||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
|
||||||
|
return days >= 0 && days <= window;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ import { useApplicationPayment } from "../../payments/hooks/useApplicationPaymen
|
|||||||
* The stages a document passes through, for the progress stepper. Derived
|
* The stages a document passes through, for the progress stepper. Derived
|
||||||
* from the status the API moves, never stored separately.
|
* from the status the API moves, never stored separately.
|
||||||
*/
|
*/
|
||||||
const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
|
type Stage = { label: string; statuses: SeafarerDocumentStatus[] };
|
||||||
|
|
||||||
|
const STAGES: Stage[] = [
|
||||||
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
|
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
|
||||||
{ label: "Payment", statuses: ["PAYMENT_PENDING"] },
|
{ label: "Payment", statuses: ["PAYMENT_PENDING"] },
|
||||||
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
|
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
|
||||||
@@ -57,9 +59,26 @@ const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
|
|||||||
{ label: "Issued", statuses: ["ISSUED"] },
|
{ label: "Issued", statuses: ["ISSUED"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function stageIndexFor(status: SeafarerDocumentStatus): number {
|
/**
|
||||||
|
* Whether this document is handed over at the counter. Configured per type
|
||||||
|
* on the backoffice Behaviour tab (`requiresIssuanceScheduling`); an older
|
||||||
|
* server that does not report it is treated as the collected-in-person
|
||||||
|
* default the documents always had.
|
||||||
|
*/
|
||||||
|
function collectedInPerson(document: SeafarerDocument): boolean {
|
||||||
|
return document.requiresIssuanceScheduling ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stepper for one document: no pickup step for a type issued on payment. */
|
||||||
|
function stagesFor(document: SeafarerDocument): Stage[] {
|
||||||
|
return collectedInPerson(document)
|
||||||
|
? STAGES
|
||||||
|
: STAGES.filter((stage) => stage.label !== "Pickup Scheduled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageIndexFor(stages: Stage[], status: SeafarerDocumentStatus): number {
|
||||||
let reached = -1;
|
let reached = -1;
|
||||||
STAGES.forEach((stage, i) => {
|
stages.forEach((stage, i) => {
|
||||||
if (stage.statuses.includes(status)) reached = i;
|
if (stage.statuses.includes(status)) reached = i;
|
||||||
});
|
});
|
||||||
return reached;
|
return reached;
|
||||||
@@ -82,7 +101,12 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
|
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
|
||||||
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
|
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
|
||||||
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||||
const activeStep = stageIndexFor(document.status);
|
const stages = stagesFor(document);
|
||||||
|
const activeStep = stageIndexFor(stages, document.status);
|
||||||
|
// Offered inside the type's configured renewal window, and only where the
|
||||||
|
// type allows it — decided by the API, not re-derived from a date here.
|
||||||
|
const renewable = document.renewable ?? true;
|
||||||
|
const reissuable = document.reissuable ?? true;
|
||||||
|
|
||||||
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
|
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +157,7 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
|
|
||||||
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
||||||
<Stepper active={activeStep} size="sm" color="teal">
|
<Stepper active={activeStep} size="sm" color="teal">
|
||||||
{STAGES.map((stage, i) => (
|
{stages.map((stage, i) => (
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
key={stage.label}
|
key={stage.label}
|
||||||
label={stage.label}
|
label={stage.label}
|
||||||
@@ -171,7 +195,9 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
)}
|
)}
|
||||||
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
|
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
|
||||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
|
||||||
Payment received. The Authority will schedule a date for you to collect your {title}.
|
{collectedInPerson(document)
|
||||||
|
? `Payment received. The Authority will schedule a date for you to collect your ${title}.`
|
||||||
|
: `Payment received. Your ${title} is being issued.`}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
|
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
|
||||||
@@ -193,24 +219,28 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
|||||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||||
Download PDF
|
Download PDF
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{renewable && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="default"
|
size="xs"
|
||||||
leftSection={<IconRefresh size={14} />}
|
variant="default"
|
||||||
loading={renewing}
|
leftSection={<IconRefresh size={14} />}
|
||||||
onClick={() => renewOrReplace(() => renew(document.id))}
|
loading={renewing}
|
||||||
>
|
onClick={() => renewOrReplace(() => renew(document.id))}
|
||||||
Renew
|
>
|
||||||
</Button>
|
Renew
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="default"
|
{reissuable && (
|
||||||
leftSection={<IconReplace size={14} />}
|
<Button
|
||||||
loading={replacing}
|
size="xs"
|
||||||
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
|
variant="default"
|
||||||
>
|
leftSection={<IconReplace size={14} />}
|
||||||
Report Lost/Damaged
|
loading={replacing}
|
||||||
</Button>
|
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
|
||||||
|
>
|
||||||
|
Report Lost/Damaged
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -151,6 +151,79 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
expiringSoon: 'በቅርቡ የሚያበቃ',
|
expiringSoon: 'በቅርቡ የሚያበቃ',
|
||||||
|
activeLicenses: 'ንቁ ፍቃዶች',
|
||||||
|
pendingApplications: 'በመጠባበቅ ላይ ያሉ ማመልከቻዎች',
|
||||||
|
approved: 'የጸደቁ',
|
||||||
|
documents: 'ሰነዶች',
|
||||||
|
upToDate: 'የተሟሉ',
|
||||||
|
allInGoodStanding: 'ንቁ እና ህጋዊ',
|
||||||
|
inReview: 'በግምገማ ላይ',
|
||||||
|
passRate: 'ዝግጁ / የተሰጠ',
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
statusTitle: 'የማመልከቻ ሁኔታ',
|
||||||
|
activityTitle: 'የማመልከቻ እንቅስቃሴ',
|
||||||
|
activitySubtitle: 'የወርሃዊ ማመልከቻዎች እና ማጽደቆች',
|
||||||
|
viewTimeline: 'የጊዜ ሰሌዳ',
|
||||||
|
viewCategory: 'በአገልግሎት አይነት',
|
||||||
|
categoryTitle: 'ማመልከቻዎች በአገልግሎት ዘርፍ',
|
||||||
|
categoryCount: 'ማመልከቻዎች',
|
||||||
|
approved: 'የጸደቀ',
|
||||||
|
underReview: 'በግምገማ ላይ',
|
||||||
|
draftPending: 'ረቂቅ / በመጠባበቅ ላይ',
|
||||||
|
total: 'ጠቅላላ',
|
||||||
|
submitted: 'የገቡ',
|
||||||
|
noData: 'እስካሁን ምንም እንቅስቃሴ አልተመዘገበም',
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: 'የኢ.ባ.ባ ፖርታል ጅምር መመሪያ',
|
||||||
|
subtitle: 'የተረጋገጠ ፍቃድ ለማግኘት እነዚህን 3 ቀላል ደረጃዎች ያጠናቅቁ።',
|
||||||
|
progress: 'ከ{{total}} ውስጥ {{completed}} ተጠናቋል',
|
||||||
|
step1: 'የመገለጫ ዝርዝሮችን ያረጋግጡ',
|
||||||
|
step1Desc: 'የእውቂያ መረጃ እና ምዝገባ ዝርዝሮች',
|
||||||
|
step2: 'ሰነዶችን ወደ ቮልት ይስቀሉ',
|
||||||
|
step2Desc: 'ፓስፖርት፣ መታወቂያ ወይም የምስክር ወረቀቶችን ይስቀሉ',
|
||||||
|
step3: 'የመጀመሪያውን ፍቃድ ያመልክቱ',
|
||||||
|
step3Desc: 'ካታሎጉን ያስሱ እና ማመልከቻዎን ያስገቡ',
|
||||||
|
viewVault: 'ቮልት ክፈት',
|
||||||
|
browseCatalogue: 'ካታሎግ አስስ',
|
||||||
|
completed: 'ተጠናቋል',
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
button: 'የፍቃዶች መግለጫ አውርድ',
|
||||||
|
title: 'ኦፊሴላዊ የፍቃድ መግለጫ',
|
||||||
|
subtitle: 'የተሰጡ ፍቃዶች እና በማመልከት ላይ ያሉ ማመልከቻዎች መዝገብ',
|
||||||
|
accountHolder: 'የመለያው ባለቤት',
|
||||||
|
issueDate: 'የወጣበት ቀን',
|
||||||
|
activeLicencesCount: 'ንቁ ፍቃዶች',
|
||||||
|
pendingApplicationsCount: 'በግምገማ ላይ',
|
||||||
|
verifiedDocumentsCount: 'የተረጋገጡ ሰነዶች',
|
||||||
|
printButton: 'አትም / ፒዲኤፍ አስቀምጥ',
|
||||||
|
close: 'ዝጋ',
|
||||||
|
officialFooter: 'በኢትዮጵያ የባህር ባለስልጣን በኤሌክትሮኒክ የተሰጠ። ለማረጋገጥ licensing@ema.gov.et ያግኙ።',
|
||||||
|
},
|
||||||
|
quickActionsList: {
|
||||||
|
applyLicense: 'ለአዲስ ፍቃድ ያመልክቱ',
|
||||||
|
applyLicenseDesc: 'ካታሎግ ያስሱ እና ማመልከቻ ይጀምሩ',
|
||||||
|
uploadDocument: 'ሰነድ ይስቀሉ',
|
||||||
|
uploadDocumentDesc: 'የግል መገለጫ ቮልት ሰነዶችን ያስተዳድሩ',
|
||||||
|
notifications: 'ማሳወቂያዎች',
|
||||||
|
notificationsDesc: 'የቅርብ ጊዜ ዝመናዎችን ያረጋግጡ',
|
||||||
|
support: 'ድጋፍ ያግኙ',
|
||||||
|
supportDesc: 'ጥያቄዎች እና ቴክኒካዊ ድጋፍ',
|
||||||
|
},
|
||||||
|
supportModal: {
|
||||||
|
title: 'የኢ.ባ.ባ ድጋፍ እና እገዛ',
|
||||||
|
description: 'በማመልከቻዎ ወይም በፍቃድዎ ላይ እርዳታ ይፈልጋሉ? የፍቃድ ድጋፍ ቡድናችን በስራ ሰዓታት ውስጥ ይገኛል።',
|
||||||
|
emailLabel: 'የኢሜይል ድጋፍ',
|
||||||
|
email: 'licensing@ema.gov.et',
|
||||||
|
phoneLabel: 'የስልክ መስመር',
|
||||||
|
phone: '+251 11 551 8855',
|
||||||
|
hoursLabel: 'የስራ ሰዓት',
|
||||||
|
hours: 'ሰኞ – አርብ፣ 2:30 ጠዋት – 11:30 ከሰዓት',
|
||||||
|
officeLabel: 'ዋና መስሪያ ቤት',
|
||||||
|
office: 'የኢትዮጵያ የባህር ባለስልጣን፣ አዲስ አበባ፣ ኢትዮጵያ',
|
||||||
|
close: 'ዝጋ',
|
||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
myLicences: {
|
myLicences: {
|
||||||
@@ -171,6 +244,13 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
application: 'ማመልከቻ',
|
application: 'ማመልከቻ',
|
||||||
|
submitted: 'የገባበት ቀን',
|
||||||
|
type: 'አይነት',
|
||||||
|
status: 'ሁኔታ',
|
||||||
|
all: 'ሁሉም',
|
||||||
|
inReview: 'በግምገማ ላይ',
|
||||||
|
approved: 'የጸደቀ',
|
||||||
|
actionRequired: 'እርምጃ የሚያስፈልገው',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -223,6 +303,7 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
continue: 'ቀጥል',
|
continue: 'ቀጥል',
|
||||||
|
closed: 'አገልግሎቱ ቆሟል',
|
||||||
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
||||||
pay: '{{amount}} {{currency}} ክፈል',
|
pay: '{{amount}} {{currency}} ክፈል',
|
||||||
certificate: 'የምስክር ወረቀት',
|
certificate: 'የምስክር ወረቀት',
|
||||||
@@ -953,6 +1034,9 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
licenseApplication: {
|
licenseApplication: {
|
||||||
|
typeClosedTitle: 'ይህ አገልግሎት ከአሁን በኋላ አይሰጥም',
|
||||||
|
typeClosed:
|
||||||
|
'የ{{name}} አገልግሎት መሰጠቱ ቆሟል። ይህ ረቂቅ ከአሁን በኋላ ሊጠናቀቅ ወይም ሊገባ አይችልም፤ ከማመልከቻዎች ዝርዝርዎ ማጥፋት ይችላሉ።',
|
||||||
loading: 'ማመልከቻ በመጫን ላይ…',
|
loading: 'ማመልከቻ በመጫን ላይ…',
|
||||||
fee: 'ክፍያ፡ {{amount}} {{currency}}',
|
fee: 'ክፍያ፡ {{amount}} {{currency}}',
|
||||||
review: 'ግምገማ',
|
review: 'ግምገማ',
|
||||||
|
|||||||
@@ -151,6 +151,79 @@ export const en = {
|
|||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
expiringSoon: 'Expiring soon',
|
expiringSoon: 'Expiring soon',
|
||||||
|
activeLicenses: 'Active Licenses',
|
||||||
|
pendingApplications: 'Pending Applications',
|
||||||
|
approved: 'Approved',
|
||||||
|
documents: 'Documents',
|
||||||
|
upToDate: 'Up to date',
|
||||||
|
allInGoodStanding: 'Active & valid',
|
||||||
|
inReview: 'In review',
|
||||||
|
passRate: 'Ready / Issued',
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
statusTitle: 'Application Status',
|
||||||
|
activityTitle: 'Application Activity',
|
||||||
|
activitySubtitle: 'Monthly filings & approvals',
|
||||||
|
viewTimeline: 'Timeline',
|
||||||
|
viewCategory: 'By Category',
|
||||||
|
categoryTitle: 'Applications by Service',
|
||||||
|
categoryCount: 'Applications',
|
||||||
|
approved: 'Approved',
|
||||||
|
underReview: 'Under review',
|
||||||
|
draftPending: 'Draft / pending',
|
||||||
|
total: 'Total',
|
||||||
|
submitted: 'Submitted',
|
||||||
|
noData: 'No activity recorded yet',
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: 'Getting Started with EMA Portal',
|
||||||
|
subtitle: 'Complete these 3 simple steps to get certified and verified.',
|
||||||
|
progress: '{{completed}} of {{total}} completed',
|
||||||
|
step1: 'Verify Profile Details',
|
||||||
|
step1Desc: 'Contact info and account registration',
|
||||||
|
step2: 'Upload Documents to Vault',
|
||||||
|
step2Desc: 'Upload passport, ID or certifications for reusable verification',
|
||||||
|
step3: 'Apply for First License',
|
||||||
|
step3Desc: 'Browse catalogue and submit your first application',
|
||||||
|
viewVault: 'Open Vault',
|
||||||
|
browseCatalogue: 'Browse Catalogue',
|
||||||
|
completed: 'Completed',
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
button: 'Export Statement',
|
||||||
|
title: 'Official Licensing Statement',
|
||||||
|
subtitle: 'Official record of issued licences and pending applications',
|
||||||
|
accountHolder: 'Account Holder',
|
||||||
|
issueDate: 'Statement Date',
|
||||||
|
activeLicencesCount: 'Active Licences',
|
||||||
|
pendingApplicationsCount: 'In Review',
|
||||||
|
verifiedDocumentsCount: 'Vault Documents',
|
||||||
|
printButton: 'Print / Save as PDF',
|
||||||
|
close: 'Close',
|
||||||
|
officialFooter: 'Issued electronically by the Ethiopian Maritime Authority. For validation, contact licensing@ema.gov.et.',
|
||||||
|
},
|
||||||
|
quickActionsList: {
|
||||||
|
applyLicense: 'Apply for new license',
|
||||||
|
applyLicenseDesc: 'Explore catalogue and start application',
|
||||||
|
uploadDocument: 'Upload a document',
|
||||||
|
uploadDocumentDesc: 'Manage verified profile vault documents',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
notificationsDesc: 'Check recent authority updates',
|
||||||
|
support: 'Contact support',
|
||||||
|
supportDesc: 'Inquiries & technical assistance',
|
||||||
|
},
|
||||||
|
supportModal: {
|
||||||
|
title: 'EMA Support & Assistance',
|
||||||
|
description: 'Need help with your application or credentials? Our licensing support team is available during working hours.',
|
||||||
|
emailLabel: 'Email Support',
|
||||||
|
email: 'licensing@ema.gov.et',
|
||||||
|
phoneLabel: 'Helpline',
|
||||||
|
phone: '+251 11 551 8855',
|
||||||
|
hoursLabel: 'Working Hours',
|
||||||
|
hours: 'Mon – Fri, 8:30 AM – 5:30 PM (EAT)',
|
||||||
|
officeLabel: 'Headquarters',
|
||||||
|
office: 'Ethiopian Maritime Authority, Addis Ababa, Ethiopia',
|
||||||
|
close: 'Close',
|
||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
myLicences: {
|
myLicences: {
|
||||||
@@ -171,6 +244,13 @@ export const en = {
|
|||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
application: 'Application',
|
application: 'Application',
|
||||||
|
submitted: 'Submitted',
|
||||||
|
type: 'Type',
|
||||||
|
status: 'Status',
|
||||||
|
all: 'All',
|
||||||
|
inReview: 'In Review',
|
||||||
|
approved: 'Approved',
|
||||||
|
actionRequired: 'Action Required',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -223,6 +303,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
continue: 'Continue',
|
continue: 'Continue',
|
||||||
|
closed: 'No longer offered',
|
||||||
fixResubmit: 'Fix & resubmit',
|
fixResubmit: 'Fix & resubmit',
|
||||||
pay: 'Pay {{amount}} {{currency}}',
|
pay: 'Pay {{amount}} {{currency}}',
|
||||||
certificate: 'Certificate',
|
certificate: 'Certificate',
|
||||||
@@ -959,6 +1040,9 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
licenseApplication: {
|
licenseApplication: {
|
||||||
|
typeClosedTitle: 'This service is no longer offered',
|
||||||
|
typeClosed:
|
||||||
|
'EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.',
|
||||||
loading: 'Loading Application…',
|
loading: 'Loading Application…',
|
||||||
fee: 'Fee: {{amount}} {{currency}}',
|
fee: 'Fee: {{amount}} {{currency}}',
|
||||||
review: 'Review',
|
review: 'Review',
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export const router = createBrowserRouter([
|
|||||||
path: "/basic-training-certificate",
|
path: "/basic-training-certificate",
|
||||||
element: (
|
element: (
|
||||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||||
<SeamanBookPage />
|
<SeamanBookPage service="BTC" />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ export default defineConfig({
|
|||||||
port: 4200,
|
port: 4200,
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
"/api": {
|
||||||
target: 'https://ema-api-dev.triaplc.com',
|
target: process.env.VITE_API_PROXY_TARGET || "https://ema-api-dev.triaplc.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { resolveSessionContext } from "../session";
|
|||||||
export const BASE_API_URL =
|
export const BASE_API_URL =
|
||||||
(import.meta as { env?: Record<string, string> }).env?.[
|
(import.meta as { env?: Record<string, string> }).env?.[
|
||||||
"VITE_BASE_API_URL"
|
"VITE_BASE_API_URL"
|
||||||
]?.trim() || "http://localhost:3000/api";
|
]?.trim() || "https://ema-api-dev.triaplc.com/api";
|
||||||
|
|
||||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||||
let _onAuthFailure: (() => void) | null = null;
|
let _onAuthFailure: (() => void) | null = null;
|
||||||
|
|||||||
@@ -74,6 +74,51 @@ const handlers: MockHandler[] = [
|
|||||||
pattern: /^\/license-types$/,
|
pattern: /^\/license-types$/,
|
||||||
respond: () => paginated(clone(mockLicenseTypes)),
|
respond: () => paginated(clone(mockLicenseTypes)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
pattern: /^\/license-types$/,
|
||||||
|
respond: (req) => {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const key = String(body.key ?? '').trim().toUpperCase();
|
||||||
|
if (mockLicenseTypes.some((t) => t.key === key)) return undefined;
|
||||||
|
const created = {
|
||||||
|
id: `lt-${key.toLowerCase()}`,
|
||||||
|
category: 'MARITIME_PERSONNEL',
|
||||||
|
familyKind: 'LOGISTICS_LICENSE',
|
||||||
|
serviceKind: 'LICENSE',
|
||||||
|
workflowProfile: 'STANDARD',
|
||||||
|
feeNewApplication: null,
|
||||||
|
feeRenewal: null,
|
||||||
|
feeCurrency: 'ETB',
|
||||||
|
capitalThreshold: null,
|
||||||
|
validityMonths: 12,
|
||||||
|
slaHours: null,
|
||||||
|
inspectionRequired: true,
|
||||||
|
issuesCertificate: true,
|
||||||
|
renewalEnabled: true,
|
||||||
|
requiresIssuanceScheduling: false,
|
||||||
|
requiresOperatorMode: true,
|
||||||
|
formSchema: { sections: [] },
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: mockLicenseTypes.length,
|
||||||
|
...body,
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
(mockLicenseTypes as unknown[]).push(created);
|
||||||
|
return clone(created);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
pattern: /^\/license-types\/([\w-]+)\/status$/,
|
||||||
|
respond: (req, match) => {
|
||||||
|
const found = mockLicenseTypes.find((t) => t.id === match[1]);
|
||||||
|
if (!found) return undefined;
|
||||||
|
const body = (req.body ?? {}) as { isActive?: boolean };
|
||||||
|
(found as { isActive: boolean }).isActive = body.isActive ?? found.isActive;
|
||||||
|
return clone(found);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pattern: /^\/license-types\/requirements\/([\w-]+)$/,
|
pattern: /^\/license-types\/requirements\/([\w-]+)$/,
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
|
|||||||
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
resolvedAt: null,
|
resolvedAt: null,
|
||||||
|
valueChangedAt: null,
|
||||||
createdAt: application.submittedAt ?? application.createdAt,
|
createdAt: application.submittedAt ?? application.createdAt,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -417,6 +418,7 @@ export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
|
|||||||
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
resolvedAt: null,
|
resolvedAt: null,
|
||||||
|
valueChangedAt: null,
|
||||||
createdAt: application.submittedAt ?? application.createdAt,
|
createdAt: application.submittedAt ?? application.createdAt,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { baseApi } from '../../base-api';
|
import { baseApi } from '../../base-api';
|
||||||
import type {
|
import type {
|
||||||
|
AdminDashboardAnalytics,
|
||||||
|
ApplicantDashboardSummary,
|
||||||
ExamStateView,
|
ExamStateView,
|
||||||
AppNotification,
|
AppNotification,
|
||||||
ApplicationDetail,
|
ApplicationDetail,
|
||||||
@@ -9,6 +11,7 @@ import type {
|
|||||||
Attachment,
|
Attachment,
|
||||||
Department,
|
Department,
|
||||||
DocumentRequirement,
|
DocumentRequirement,
|
||||||
|
FamilyKind,
|
||||||
FormSchemaPalette,
|
FormSchemaPalette,
|
||||||
FormSectionConfig,
|
FormSectionConfig,
|
||||||
InitiatePaymentResult,
|
InitiatePaymentResult,
|
||||||
@@ -19,6 +22,7 @@ import type {
|
|||||||
LicenseCategoryDefinition,
|
LicenseCategoryDefinition,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
LicenseType,
|
LicenseType,
|
||||||
|
LicenseTypeCreate,
|
||||||
LicenseTypeRequirements,
|
LicenseTypeRequirements,
|
||||||
OperatorType,
|
OperatorType,
|
||||||
AssignableOfficer,
|
AssignableOfficer,
|
||||||
@@ -173,6 +177,38 @@ export const licensingApi = baseApi
|
|||||||
providesTags: () => [listTag('LicenseType')],
|
providesTags: () => [listTag('LicenseType')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the licence type row.
|
||||||
|
*
|
||||||
|
* The one write on this resource with no screen until now: everything
|
||||||
|
* after creation — form, documents, staff rules, fees, validity,
|
||||||
|
* certificate design — had an editor, but the type itself had to be
|
||||||
|
* added by seed or by calling the API directly.
|
||||||
|
*/
|
||||||
|
createLicenseType: builder.mutation<LicenseType, LicenseTypeCreate>({
|
||||||
|
query: (body) => ({ url: '/license-types', method: 'POST', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseType')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the row's own fields. The narrow routes (`/fees`,
|
||||||
|
* `/validity`, `/behavior`, `/form-schema`) are still the way to change
|
||||||
|
* what they own; this is the catalogue entry itself — key, names,
|
||||||
|
* category, family kind, prefix, sort order, active.
|
||||||
|
*/
|
||||||
|
updateLicenseType: builder.mutation<
|
||||||
|
LicenseType,
|
||||||
|
{ id: string } & Partial<LicenseTypeCreate>
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/license-types/${id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fee configuration, for licensing administrators.
|
* Fee configuration, for licensing administrators.
|
||||||
*
|
*
|
||||||
@@ -222,6 +258,7 @@ export const licensingApi = baseApi
|
|||||||
id: string;
|
id: string;
|
||||||
workflowProfile?: WorkflowProfile;
|
workflowProfile?: WorkflowProfile;
|
||||||
serviceKind?: ServiceKind;
|
serviceKind?: ServiceKind;
|
||||||
|
familyKind?: FamilyKind;
|
||||||
completionEffect?: CompletionEffect | null;
|
completionEffect?: CompletionEffect | null;
|
||||||
certificateCategory?: CertificateCategory | null;
|
certificateCategory?: CertificateCategory | null;
|
||||||
requiresExamination?: boolean;
|
requiresExamination?: boolean;
|
||||||
@@ -252,6 +289,26 @@ export const licensingApi = baseApi
|
|||||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens or closes a type to new applications. Applications already in
|
||||||
|
* flight hold the type by id and keep running; only the portal
|
||||||
|
* catalogue changes. Closing also notifies every holder, applicant and
|
||||||
|
* drafter of the type, and freezes their drafts — server-side, so the
|
||||||
|
* same happens whichever client flips it.
|
||||||
|
*/
|
||||||
|
updateLicenseStatus: builder.mutation<
|
||||||
|
LicenseType,
|
||||||
|
{ id: string; isActive: boolean }
|
||||||
|
>({
|
||||||
|
query: ({ id, isActive }) => ({
|
||||||
|
url: `/license-types/${id}/status`,
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { isActive },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
|
||||||
|
}),
|
||||||
|
|
||||||
/** Validity is edited beside the certificate design, not with the fees. */
|
/** Validity is edited beside the certificate design, not with the fees. */
|
||||||
updateLicenseValidity: builder.mutation<
|
updateLicenseValidity: builder.mutation<
|
||||||
LicenseType,
|
LicenseType,
|
||||||
@@ -492,6 +549,11 @@ export const licensingApi = baseApi
|
|||||||
providesTags: () => [listTag('LicenseApplication')],
|
providesTags: () => [listTag('LicenseApplication')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getMyDashboardSummary: builder.query<ApplicantDashboardSummary, void>({
|
||||||
|
query: () => ({ url: '/license-applications/mine/summary' }),
|
||||||
|
providesTags: () => [listTag('LicenseApplication'), listTag('License')],
|
||||||
|
}),
|
||||||
|
|
||||||
getApplication: builder.query<ApplicationDetail, string>({
|
getApplication: builder.query<ApplicationDetail, string>({
|
||||||
query: (id) => ({ url: `/license-applications/${id}` }),
|
query: (id) => ({ url: `/license-applications/${id}` }),
|
||||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||||
@@ -746,6 +808,17 @@ export const licensingApi = baseApi
|
|||||||
providesTags: () => [listTag('ApplicationQueue')],
|
providesTags: () => [listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getAdminDashboardAnalytics: builder.query<
|
||||||
|
AdminDashboardAnalytics,
|
||||||
|
{ period?: string } | void
|
||||||
|
>({
|
||||||
|
query: (args) => ({
|
||||||
|
url: '/license-application-review/dashboard-analytics',
|
||||||
|
params: args?.period ? { period: args.period } : undefined,
|
||||||
|
}),
|
||||||
|
providesTags: () => [listTag('ApplicationQueue'), listTag('License')],
|
||||||
|
}),
|
||||||
|
|
||||||
getApplicationForReview: builder.query<ApplicationDetail, string>({
|
getApplicationForReview: builder.query<ApplicationDetail, string>({
|
||||||
query: (id) => ({ url: `/license-application-review/${id}` }),
|
query: (id) => ({ url: `/license-application-review/${id}` }),
|
||||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||||
@@ -1364,6 +1437,8 @@ export const licensingApi = baseApi
|
|||||||
export const {
|
export const {
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetLicenseCategoriesQuery,
|
useGetLicenseCategoriesQuery,
|
||||||
|
useCreateLicenseTypeMutation,
|
||||||
|
useUpdateLicenseTypeMutation,
|
||||||
useUpdateLicenseFeesMutation,
|
useUpdateLicenseFeesMutation,
|
||||||
useUpdateLicenseBehaviorMutation,
|
useUpdateLicenseBehaviorMutation,
|
||||||
useUpdateFormSchemaMutation,
|
useUpdateFormSchemaMutation,
|
||||||
@@ -1389,11 +1464,13 @@ export const {
|
|||||||
useUpdateRankMutation,
|
useUpdateRankMutation,
|
||||||
useDeleteRankMutation,
|
useDeleteRankMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
|
useUpdateLicenseStatusMutation,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
useCreateApplicationMutation,
|
useCreateApplicationMutation,
|
||||||
useGetExamStateForApplicationQuery,
|
useGetExamStateForApplicationQuery,
|
||||||
useDiscardApplicationMutation,
|
useDiscardApplicationMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
|
useGetMyDashboardSummaryQuery,
|
||||||
useGetApplicationQuery,
|
useGetApplicationQuery,
|
||||||
useInitiatePaymentMutation,
|
useInitiatePaymentMutation,
|
||||||
useBypassPaymentMutation,
|
useBypassPaymentMutation,
|
||||||
@@ -1417,6 +1494,7 @@ export const {
|
|||||||
useGetAssignedToMeQuery,
|
useGetAssignedToMeQuery,
|
||||||
useGetAllApplicationsQuery,
|
useGetAllApplicationsQuery,
|
||||||
useGetQueueCountsQuery,
|
useGetQueueCountsQuery,
|
||||||
|
useGetAdminDashboardAnalyticsQuery,
|
||||||
useGetLicenseTemplatesQuery,
|
useGetLicenseTemplatesQuery,
|
||||||
useGetTemplateVariablesQuery,
|
useGetTemplateVariablesQuery,
|
||||||
useGetBuiltInTemplateQuery,
|
useGetBuiltInTemplateQuery,
|
||||||
|
|||||||
@@ -411,6 +411,18 @@ const ERROR_MESSAGES: Record<string, string> = {
|
|||||||
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
|
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
|
||||||
form_schema_missing_protected_paths:
|
form_schema_missing_protected_paths:
|
||||||
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
|
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
|
||||||
|
license_type_key_taken:
|
||||||
|
'A licence type with this key already exists (it may be archived). Choose a different key.',
|
||||||
|
license_type_key_in_use:
|
||||||
|
'Applications already reference this licence type, so its key can no longer be changed.',
|
||||||
|
invalid_form_schema:
|
||||||
|
'The form schema is not consistent — a condition or field reference points at something the form does not define.',
|
||||||
|
|
||||||
|
// Exam certification catalogue.
|
||||||
|
rank_not_found:
|
||||||
|
'That STCW rank is not in the ranks registry, or has been switched off. Pick a rank from the list.',
|
||||||
|
certification_in_use:
|
||||||
|
'Exam sessions or question-bank items still use this certification. Reassign or remove those first.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
||||||
|
|||||||
@@ -277,6 +277,39 @@ export interface LicenseType {
|
|||||||
prerequisiteLicenseKeys?: string[] | null;
|
prerequisiteLicenseKeys?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /license-types` — the row itself, before behaviour, fees, the
|
||||||
|
* form or its document slots are configured.
|
||||||
|
*
|
||||||
|
* Mirrors `CreateLicenseTypeDto` field for field. Create the type inactive: an
|
||||||
|
* active type with no form schema and no document requirements is immediately
|
||||||
|
* visible to applicants.
|
||||||
|
*/
|
||||||
|
export interface LicenseTypeCreate {
|
||||||
|
key: string;
|
||||||
|
name: Bilingual;
|
||||||
|
description?: Bilingual;
|
||||||
|
category?: LicenseCategory;
|
||||||
|
/** Omitted, the server derives it from `key` — see `FamilyKind`. */
|
||||||
|
familyKind?: FamilyKind;
|
||||||
|
/** Licence or registration; splits the catalogues without splitting storage. */
|
||||||
|
serviceKind?: ServiceKind;
|
||||||
|
/** Which transition set the application runs. */
|
||||||
|
workflowProfile?: WorkflowProfile;
|
||||||
|
certificatePrefix: string;
|
||||||
|
feeNewApplication?: number | null;
|
||||||
|
feeRenewal?: number | null;
|
||||||
|
feeCurrency?: string;
|
||||||
|
capitalThreshold?: number | null;
|
||||||
|
validityMonths?: number;
|
||||||
|
inspectionRequired?: boolean;
|
||||||
|
issuesCertificate?: boolean;
|
||||||
|
renewalEnabled?: boolean;
|
||||||
|
formSchema?: { sections: FormSectionConfig[] };
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** What kind of document a certificate type produces. */
|
/** What kind of document a certificate type produces. */
|
||||||
export type CertificateCategory =
|
export type CertificateCategory =
|
||||||
"COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL";
|
"COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL";
|
||||||
@@ -487,6 +520,15 @@ export interface ApplicationRemark {
|
|||||||
remark: string;
|
remark: string;
|
||||||
isResolved: boolean;
|
isResolved: boolean;
|
||||||
resolvedAt: string | null;
|
resolvedAt: string | null;
|
||||||
|
/**
|
||||||
|
* When the applicant actually changed the value this remark points at.
|
||||||
|
*
|
||||||
|
* `resolvedAt` only says the item was ticked off — the portal bulk-resolves
|
||||||
|
* every remark of the round right before resubmitting, so it proves nothing.
|
||||||
|
* This is stamped by the section save or document re-upload itself, which
|
||||||
|
* makes it the only trustworthy answer to "what did they actually correct?".
|
||||||
|
*/
|
||||||
|
valueChangedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,3 +1002,121 @@ export interface ExamStateView {
|
|||||||
/** Whether the exam engine produced the mark, or a person did. */
|
/** Whether the exam engine produced the mark, or a person did. */
|
||||||
autoGraded: boolean | null;
|
autoGraded: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApplicantDashboardSummary {
|
||||||
|
counts: {
|
||||||
|
totalApplications: number;
|
||||||
|
activeLicenses: number;
|
||||||
|
expiringSoonLicenses: number;
|
||||||
|
pendingApplications: number;
|
||||||
|
approvedApplications: number;
|
||||||
|
draftApplications: number;
|
||||||
|
actionRequiredApplications: number;
|
||||||
|
};
|
||||||
|
statusDistribution: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
monthlyTrend: Array<{
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
submitted: number;
|
||||||
|
approved: number;
|
||||||
|
}>;
|
||||||
|
categoryBreakdown: Array<{
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardAnalytics {
|
||||||
|
kpis: {
|
||||||
|
totalApplications: number;
|
||||||
|
unclaimedQueue: number;
|
||||||
|
assignedToMe: number;
|
||||||
|
inProgressTotal: number;
|
||||||
|
needsApplicant: number;
|
||||||
|
awaitingPayment: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
overdueSla: number;
|
||||||
|
withinSla: number;
|
||||||
|
slaComplianceRate: number;
|
||||||
|
activeLicenses: number;
|
||||||
|
revenue: {
|
||||||
|
totalCollected: number;
|
||||||
|
pendingAmount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
statusDistribution: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
monthlyTrend: Array<{
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
submitted: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
revenue: number;
|
||||||
|
}>;
|
||||||
|
categoryBreakdown: Array<{
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
departmentSummary: {
|
||||||
|
seafarerRegistrations: {
|
||||||
|
total: number;
|
||||||
|
submitted: number;
|
||||||
|
approved: number;
|
||||||
|
};
|
||||||
|
seamanBooks: {
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
issued: number;
|
||||||
|
};
|
||||||
|
btc: {
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
issued: number;
|
||||||
|
};
|
||||||
|
vessels: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
oldestUnclaimed: Array<{
|
||||||
|
id: string;
|
||||||
|
applicationNumber: string;
|
||||||
|
companyName: string;
|
||||||
|
licenseTypeName: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
submittedAt: string;
|
||||||
|
slaHours: number | null;
|
||||||
|
}>;
|
||||||
|
urgentApplications: Array<{
|
||||||
|
id: string;
|
||||||
|
applicationNumber: string;
|
||||||
|
companyName: string | null;
|
||||||
|
licenseTypeName: string;
|
||||||
|
category: string;
|
||||||
|
submittedAt: string | null;
|
||||||
|
hoursLeft: number;
|
||||||
|
isOverdue: boolean;
|
||||||
|
}>;
|
||||||
|
officerWorkload: Array<{
|
||||||
|
officerId: string;
|
||||||
|
officerName: string;
|
||||||
|
activeCount: number;
|
||||||
|
overdueCount: number;
|
||||||
|
onScheduleCount: number;
|
||||||
|
}>;
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export interface SeafarerDocument {
|
|||||||
issuedAt: string | null;
|
issuedAt: string | null;
|
||||||
rejectionReason: string | null;
|
rejectionReason: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/**
|
||||||
|
* Policy from the document's licence-type row, as configured on the
|
||||||
|
* backoffice Behaviour tab. Present on `/seafarer-documents/mine` so the
|
||||||
|
* portal shows the pickup step only for a type that has one, and offers
|
||||||
|
* Renew / Replace only inside the configured window — the same flags
|
||||||
|
* `/licenses/mine` carries for licences. Absent on older servers.
|
||||||
|
*/
|
||||||
|
requiresIssuanceScheduling?: boolean;
|
||||||
|
daysUntilExpiry?: number | null;
|
||||||
|
renewable?: boolean;
|
||||||
|
reissuable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeafarerDocumentApplicant {
|
export interface SeafarerDocumentApplicant {
|
||||||
|
|||||||
Reference in New Issue
Block a user