mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 21:15:42 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -129,7 +129,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
// against an expiry that never arrives, so none of them are asked for.
|
||||
const expires = draft.validityDays !== null || draft.validityMonths > 0;
|
||||
|
||||
// The server's floor for a stated term is 6 months, so no-expiry is a state
|
||||
// The server's floor for a stated term is 1 month, so no-expiry is a state
|
||||
// this form can hold and edit around but cannot switch a type into. Offered
|
||||
// only where it is already what the type is, rather than as an option whose
|
||||
// save would be refused.
|
||||
@@ -140,8 +140,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
// Only the settings this form actually asked for. The endpoint patches, so
|
||||
// an omitted field keeps its stored value — and the fields hidden above are
|
||||
// hidden precisely because the type has no such policy, which the server
|
||||
// stores as a zero its own validators then refuse (`validityMonths` has a
|
||||
// floor of 6, `renewalWindowDays` of 1). Echoing those back is what made
|
||||
// stores as a zero its own validators then refuse (`validityMonths` and
|
||||
// `renewalWindowDays` both have a floor of 1). Echoing those back is what made
|
||||
// saving an ownership transfer fail outright.
|
||||
const {
|
||||
validityMonths,
|
||||
@@ -152,7 +152,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
} = draft;
|
||||
|
||||
// A type switched from "does not expire" straight to a term in days still
|
||||
// carries `validityMonths: 0`, which the server's 6-month floor refuses.
|
||||
// carries `validityMonths: 0`, which the server's 1-month floor refuses.
|
||||
// Days win over months at issuance, so the zero is simply left alone.
|
||||
const term = expires
|
||||
? {
|
||||
@@ -345,8 +345,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
else set('validityMonths', next);
|
||||
}}
|
||||
// Matches the server's ranges, so the box cannot offer a value the
|
||||
// save would reject: 1–3650 days, or 6–240 months.
|
||||
min={draft.validityDays !== null ? 1 : 6}
|
||||
// save would reject: 1–3650 days, or 1–240 months.
|
||||
min={1}
|
||||
max={draft.validityDays !== null ? 3650 : 240}
|
||||
allowNegative={false}
|
||||
disabled={!canEdit}
|
||||
@@ -388,7 +388,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||||
patch({
|
||||
validityDays: null,
|
||||
validityMonths:
|
||||
draft.validityMonths >= 6 ? draft.validityMonths : 12,
|
||||
draft.validityMonths >= 1 ? draft.validityMonths : 12,
|
||||
});
|
||||
// No expiry means no renewal policy: clear it here rather than
|
||||
// save renewal settings that could never fire.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
SegmentedControl,
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconCheck, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetPersonalDocumentsQuery,
|
||||
useLocalized,
|
||||
type ApplicationKind,
|
||||
type DocumentRequirement,
|
||||
@@ -74,7 +76,7 @@ const MIME_OPTIONS = [
|
||||
/** What a new slot accepts until someone widens it. */
|
||||
const DEFAULT_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
export type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
/**
|
||||
* Which licences a personal document is asked for.
|
||||
@@ -117,6 +119,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
requirement,
|
||||
initialDraft,
|
||||
defaultApplicationKind,
|
||||
onSave,
|
||||
palette,
|
||||
@@ -130,6 +133,8 @@ export function DocumentRequirementEditorDrawer({
|
||||
onClose: () => void;
|
||||
/** Null = adding a new requirement. */
|
||||
requirement: DocumentRequirement | null;
|
||||
/** Optional initial prefill when adding a new requirement (e.g. from personal docs). */
|
||||
initialDraft?: Partial<DraftRequirement> | null;
|
||||
defaultApplicationKind: ApplicationKind;
|
||||
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
|
||||
palette: FormSchemaPalette | undefined;
|
||||
@@ -142,7 +147,7 @@ export function DocumentRequirementEditorDrawer({
|
||||
/** The licence types this document is already scoped to. */
|
||||
scope?: PersonalScope;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [draft, setDraft] = useState<DraftRequirement>(
|
||||
emptyDraft(defaultApplicationKind, personal),
|
||||
@@ -150,36 +155,84 @@ export function DocumentRequirementEditorDrawer({
|
||||
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
||||
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [selectedPersonalKey, setSelectedPersonalKey] = useState<string | null>(null);
|
||||
const isNew = !requirement;
|
||||
|
||||
const { data: personalDocsData } = useGetPersonalDocumentsQuery(
|
||||
{ take: 100, locale: i18n.language === 'am' ? 'am' : 'en' },
|
||||
{ skip: !opened || !isNew || personal },
|
||||
);
|
||||
|
||||
const personalDocOptions = useMemo(
|
||||
() =>
|
||||
(personalDocsData?.items ?? []).map((item) => ({
|
||||
value: item.key,
|
||||
label: `${localized(item.rows[0]?.name) || item.key} (${item.key})`,
|
||||
})),
|
||||
[personalDocsData, localized],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setDraft(
|
||||
requirement
|
||||
? {
|
||||
key: requirement.key,
|
||||
name: { ...requirement.name },
|
||||
description: requirement.description ? { ...requirement.description } : undefined,
|
||||
applicationKind: requirement.applicationKind,
|
||||
mode: requirement.mode,
|
||||
conditionExpression: requirement.conditionExpression,
|
||||
allowedMimeTypes: requirement.allowedMimeTypes,
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
isPersonal: requirement.isPersonal ?? personal,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
}
|
||||
: emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
if (requirement) {
|
||||
setDraft({
|
||||
key: requirement.key,
|
||||
name: { ...requirement.name },
|
||||
description: requirement.description ? { ...requirement.description } : undefined,
|
||||
applicationKind: requirement.applicationKind,
|
||||
mode: requirement.mode,
|
||||
conditionExpression: requirement.conditionExpression,
|
||||
allowedMimeTypes: requirement.allowedMimeTypes,
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
isPersonal: requirement.isPersonal ?? personal,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
});
|
||||
setSelectedPersonalKey(null);
|
||||
} else if (initialDraft) {
|
||||
setDraft({
|
||||
...emptyDraft(defaultApplicationKind, personal),
|
||||
...initialDraft,
|
||||
});
|
||||
setSelectedPersonalKey(initialDraft.key ?? null);
|
||||
} else {
|
||||
setDraft(emptyDraft(defaultApplicationKind, personal));
|
||||
setSelectedPersonalKey(null);
|
||||
}
|
||||
setScopeIds(scope);
|
||||
setAppliesToAll(scope.length === 0);
|
||||
setKeyError(null);
|
||||
}
|
||||
// `scope` is a fresh array each render; the opened flag is what gates this.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, requirement, defaultApplicationKind, personal]);
|
||||
}, [opened, requirement, initialDraft, defaultApplicationKind, personal]);
|
||||
|
||||
function handleSelectPersonalDoc(key: string | null) {
|
||||
setSelectedPersonalKey(key);
|
||||
if (!key) return;
|
||||
const found = personalDocsData?.items?.find((item) => item.key === key);
|
||||
if (found && found.rows[0]) {
|
||||
const row = found.rows[0];
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
key: found.key,
|
||||
name: { en: row.name?.en ?? '', am: row.name?.am ?? '' },
|
||||
description: row.description
|
||||
? { en: row.description?.en ?? '', am: row.description?.am ?? '' }
|
||||
: undefined,
|
||||
allowedMimeTypes: row.allowedMimeTypes?.length
|
||||
? [...row.allowedMimeTypes]
|
||||
: d.allowedMimeTypes,
|
||||
maxSizeMb: row.maxSizeMb ?? d.maxSizeMb,
|
||||
maxFiles: row.maxFiles ?? null,
|
||||
requiresValidityDates: row.requiresValidityDates ?? false,
|
||||
allowMultiple: (row.maxFiles ?? 1) !== 1,
|
||||
}));
|
||||
setKeyError(null);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.key.trim()) {
|
||||
@@ -241,6 +294,40 @@ export function DocumentRequirementEditorDrawer({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!personal && isNew && (
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
label={t('certReq.doc.importFromPersonal', 'Import from personal document')}
|
||||
placeholder={t(
|
||||
'certReq.doc.selectPersonalDoc',
|
||||
'Select a personal document to copy details…',
|
||||
)}
|
||||
data={personalDocOptions}
|
||||
value={selectedPersonalKey}
|
||||
onChange={handleSelectPersonalDoc}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={<IconDownload size={15} />}
|
||||
description={t(
|
||||
'certReq.doc.importPersonalDesc',
|
||||
'Select documents configured in Personal Documents to add as requirements for this licence type.',
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedPersonalKey && (
|
||||
<Alert variant="light" color="teal" icon={<IconCheck size={16} />}>
|
||||
<Text fz="xs">
|
||||
{t(
|
||||
'certReq.doc.importedHelper',
|
||||
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Divider my="xs" />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('certReq.doc.key', 'Key')}
|
||||
placeholder="bank_letter"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { IconAlertCircle, IconDownload, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui';
|
||||
import {
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
import { collectConditionTargets } from '../config/schema-paths';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { describeCondition } from './ConditionBuilder';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
import {
|
||||
DocumentRequirementEditorDrawer,
|
||||
type DraftRequirement,
|
||||
} from './DocumentRequirementEditorDrawer';
|
||||
import { ImportPersonalDocumentsModal } from './ImportPersonalDocumentsModal';
|
||||
|
||||
/** Every kind the server validates a document set for, so each can be configured. */
|
||||
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL', 'REISSUE'];
|
||||
@@ -54,6 +58,8 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||
|
||||
const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null);
|
||||
const [initialDraft, setInitialDraft] = useState<Partial<DraftRequirement> | null>(null);
|
||||
const [importModalKind, setImportModalKind] = useState<ApplicationKind | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
|
||||
const requirements = useMemo(
|
||||
@@ -120,14 +126,27 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
<Card key={kind} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>{t(...KIND_LABEL[kind])}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditorState({ kind, requirement: null })}
|
||||
>
|
||||
{t('certReq.doc.add', 'Add document requirement')}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
leftSection={<IconDownload size={13} />}
|
||||
onClick={() => setImportModalKind(kind)}
|
||||
>
|
||||
{t('certReq.doc.importPersonal', 'Import from personal documents')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => {
|
||||
setInitialDraft(null);
|
||||
setEditorState({ kind, requirement: null });
|
||||
}}
|
||||
>
|
||||
{t('certReq.doc.add', 'Add document requirement')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
@@ -180,8 +199,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editorState !== null}
|
||||
onClose={() => setEditorState(null)}
|
||||
onClose={() => {
|
||||
setEditorState(null);
|
||||
setInitialDraft(null);
|
||||
}}
|
||||
requirement={editorState?.requirement ?? null}
|
||||
initialDraft={initialDraft}
|
||||
defaultApplicationKind={editorState?.kind ?? 'NEW'}
|
||||
onSave={handleSave}
|
||||
palette={palette}
|
||||
@@ -189,6 +212,28 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
saving={creating || updating}
|
||||
/>
|
||||
|
||||
{importModalKind && (
|
||||
<ImportPersonalDocumentsModal
|
||||
opened={importModalKind !== null}
|
||||
onClose={() => setImportModalKind(null)}
|
||||
licenseType={licenseType}
|
||||
applicationKind={importModalKind}
|
||||
existingKeys={requirements
|
||||
.filter((r) => r.applicationKind === importModalKind)
|
||||
.map((r) => r.key)}
|
||||
currentMaxSortOrder={requirements
|
||||
.filter((r) => r.applicationKind === importModalKind)
|
||||
.reduce((max, r) => Math.max(max, r.sortOrder ?? 0), 0)}
|
||||
onCustomizeAndAdd={(draft) => {
|
||||
setInitialDraft(draft);
|
||||
setEditorState({
|
||||
kind: (draft.applicationKind as ApplicationKind) ?? importModalKind,
|
||||
requirement: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal opened={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm">
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" variant="light">
|
||||
|
||||
@@ -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 {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
|
||||
import { Stack, Button, Modal, Text, TextInput, Textarea, Select, Card, Switch } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {IconPlus} from '@tabler/icons-react';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
||||
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage, useGetRanksQuery, useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
@@ -15,6 +15,15 @@ import { type Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
interface CertificationFormValues {
|
||||
nameEn: string;
|
||||
nameAm: string;
|
||||
descEn: string;
|
||||
descAm: string;
|
||||
rankKey: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function CertificationForm({
|
||||
editing,
|
||||
rankOptions,
|
||||
@@ -25,7 +34,7 @@ function CertificationForm({
|
||||
editing: Certification | null;
|
||||
rankOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
|
||||
onSubmit: (values: CertificationFormValues, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,14 +43,15 @@ function CertificationForm({
|
||||
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
|
||||
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
|
||||
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
|
||||
const [isActive, setIsActive] = useState(editing?.isActive ?? true);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!nameEn || !nameAm) {
|
||||
notify.error('Name fields are required');
|
||||
if (!nameEn.trim() || !nameAm.trim()) {
|
||||
notify.error(t('certification.validation.nameRequired', 'Both English and Amharic names are required'));
|
||||
return;
|
||||
}
|
||||
onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
|
||||
onSubmit({ nameEn: nameEn.trim(), nameAm: nameAm.trim(), descEn, descAm, rankKey, isActive }, !!editing);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -63,6 +73,18 @@ function CertificationForm({
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
{editing && (
|
||||
<Switch
|
||||
label={t('certification.form.isActive', 'Active')}
|
||||
description={t(
|
||||
'certification.form.isActiveHint',
|
||||
'Inactive certifications stay on existing exams but are not offered for new ones.',
|
||||
)}
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
@@ -76,8 +98,10 @@ function CertificationForm({
|
||||
export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const localized = useLocalized();
|
||||
// Server refusals (`rank_not_found`, `certification_in_use`) arrive as
|
||||
// codes; this maps them to the sentences the administrator can act on.
|
||||
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
|
||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||
const { data: rankRes } = useGetRanksQuery();
|
||||
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||
@@ -98,14 +122,14 @@ export function CertificationPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
|
||||
const handleSubmit = async (values: CertificationFormValues, isEdit: boolean) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
// null clears a previously-set rank; undefined would leave it
|
||||
// untouched server-side, so the two are not interchangeable here.
|
||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
|
||||
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey, isActive: values.isActive }).unwrap();
|
||||
notify.success(t('certification.updated'));
|
||||
} else {
|
||||
await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
|
||||
@@ -113,7 +137,7 @@ export function CertificationPage() {
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
showError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +149,7 @@ export function CertificationPage() {
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
showError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,7 +208,7 @@ export function CertificationPage() {
|
||||
</Card>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget ? localized(deleteTarget.name) : '' })}</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
||||
|
||||
@@ -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,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconLicense,
|
||||
IconAnchor,
|
||||
IconId,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -41,6 +42,7 @@ import { LocationPage } from "../../../location/pages/LocationPage";
|
||||
import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard";
|
||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||
import { NumberFormatTab } from "../../components/NumberFormatTab";
|
||||
import { LicenseTypesTab } from "../../components/LicenseTypesTab";
|
||||
import { RankDepartmentTab } from "./RankDepartmentTab";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
@@ -398,6 +400,9 @@ export function ConfigurationPage() {
|
||||
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
||||
{t("location.title")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="licenseTypes" leftSection={<IconLicense size={16} />}>
|
||||
{t("configuration.licenseTypesTab", "Licence Types")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="certifications"
|
||||
leftSection={<IconCertificate size={16} />}
|
||||
@@ -427,6 +432,10 @@ export function ConfigurationPage() {
|
||||
<CertificationPage />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="licenseTypes" pt="md">
|
||||
<LicenseTypesTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="numberFormats" pt="md">
|
||||
<NumberFormatTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -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 type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { Badge, Group, Text } from '@mantine/core';
|
||||
import { WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -9,23 +9,55 @@ import {
|
||||
|
||||
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
|
||||
{
|
||||
header: 'Number',
|
||||
header: 'Application #',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
<Text size="sm" fw={600} c="blue">
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Company',
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
header: 'Company / Applicant',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{row.original.companyName || row.original.tradeName || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'License Type',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{row.original.licenseType?.name?.en ?? 'Maritime Service'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting Since',
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt ?? row.original.createdAt}
|
||||
slaDays={
|
||||
row.original.licenseType?.slaHours
|
||||
? row.original.licenseType.slaHours / 24
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}>
|
||||
{STATUS_LABELS[row.original.status as LicenseStatus]}
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status as LicenseStatus;
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={STATUS_COLORS[status] ?? 'gray'}
|
||||
size="sm"
|
||||
>
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,18 +1,52 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCash,
|
||||
IconCertificate,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClockExclamation,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconFolders,
|
||||
IconInbox,
|
||||
IconRefresh,
|
||||
IconShip,
|
||||
IconUserCheck,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
useGetAdminDashboardAnalyticsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
useGetVesselsQuery,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
AdvancedTable,
|
||||
@@ -20,178 +54,797 @@ import {
|
||||
PageLoader,
|
||||
StatTile,
|
||||
WaitingFor,
|
||||
notify,
|
||||
useServerTable,
|
||||
} from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
import { DashboardCharts } from './DashboardCharts';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
*
|
||||
* Every figure here is counted from a queue the officer can open, and each
|
||||
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||
* series, and an earlier version of this page invented both a registration
|
||||
* trend and a staff-role breakdown rather than admit that.
|
||||
*
|
||||
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||
* queues are permission-gated and an officer without them simply gets no
|
||||
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||
* would be a lie.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<string | null>('unclaimed');
|
||||
const [period, setPeriod] = useState<string>('6m');
|
||||
|
||||
// Primary platform analytics query with time window parameter
|
||||
const analyticsQuery = useGetAdminDashboardAnalyticsQuery({ period });
|
||||
|
||||
// Review queues (for tables and fallback counts)
|
||||
const queue = useGetQueueQuery();
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||
// Department queues
|
||||
const registrations = useListSeafarerRegistrationsQuery({
|
||||
status: 'SUBMITTED',
|
||||
take: 1,
|
||||
});
|
||||
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||
kind: 'SEAMAN_BOOK',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
const btcDocuments = useListSeafarerDocumentsQuery({
|
||||
kind: 'BTC_BASIC_TRAINING',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
const vesselsQuery = useGetVesselsQuery({ search: '' });
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
const analytics = analyticsQuery.data;
|
||||
|
||||
// Real data arrays from queries
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
const allInFlight = [...unclaimed, ...inProgress];
|
||||
|
||||
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
||||
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
||||
// Fallback counts if analytics is loading
|
||||
const fallbackNeedsApplicant = allInFlight.filter(
|
||||
(a) => a.status === 'RESUBMIT_REQUIRED',
|
||||
).length;
|
||||
const fallbackAwaitingPayment = allInFlight.filter(
|
||||
(a) => a.status === 'PAYMENT_PENDING',
|
||||
).length;
|
||||
|
||||
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
||||
const byAge = [...unclaimed].sort((a, b) =>
|
||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||
);
|
||||
const paged = table.paginate(byAge.slice(0, 8));
|
||||
|
||||
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
||||
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||
q.isError ? undefined : q.data?.total;
|
||||
|
||||
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Work waiting across the Authority's review queues."
|
||||
noMargin
|
||||
/>
|
||||
// KPIs merging backend analytics with client queries for instant accuracy
|
||||
const unclaimedCount = analytics?.kpis.unclaimedQueue ?? unclaimed.length;
|
||||
const assignedToMeCount = analytics?.kpis.assignedToMe ?? inProgress.length;
|
||||
const inProgressTotalCount =
|
||||
analytics?.kpis.inProgressTotal ?? inProgress.length;
|
||||
const needsApplicantCount =
|
||||
analytics?.kpis.needsApplicant ?? fallbackNeedsApplicant;
|
||||
const awaitingPaymentCount =
|
||||
analytics?.kpis.awaitingPayment ?? fallbackAwaitingPayment;
|
||||
const overdueSlaCount = analytics?.kpis.overdueSla ?? 0;
|
||||
const activeLicensesCount = analytics?.kpis.activeLicenses ?? 0;
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
// Department counts
|
||||
const seafarerRegCount =
|
||||
analytics?.departmentSummary.seafarerRegistrations.submitted ??
|
||||
countOf(registrations);
|
||||
const seamanBookCount =
|
||||
analytics?.departmentSummary.seamanBooks.pending ?? countOf(seamanBooks);
|
||||
const btcCount =
|
||||
analytics?.departmentSummary.btc.pending ?? countOf(btcDocuments);
|
||||
const vesselCount =
|
||||
analytics?.departmentSummary.vessels.total ?? countOf(vesselsQuery);
|
||||
|
||||
// Revenue display
|
||||
const totalRevenue = analytics?.kpis.revenue.totalCollected ?? 0;
|
||||
const formattedRevenue =
|
||||
totalRevenue > 0
|
||||
? `${totalRevenue.toLocaleString('en-US')} ETB`
|
||||
: 'Active';
|
||||
|
||||
// Oldest first sorting for unclaimed worklist
|
||||
const byAge = useMemo(() => {
|
||||
return [...unclaimed].sort((a, b) =>
|
||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||
);
|
||||
}, [unclaimed]);
|
||||
|
||||
const pagedUnclaimed = table.paginate(byAge.slice(0, 8));
|
||||
|
||||
// Urgent SLA applications list
|
||||
const urgentApps = analytics?.urgentApplications ?? [];
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
analyticsQuery.refetch();
|
||||
queue.refetch();
|
||||
mine.refetch();
|
||||
registrations.refetch();
|
||||
seamanBooks.refetch();
|
||||
notify.success('Dashboard metrics refreshed.');
|
||||
};
|
||||
|
||||
const periodLabelMap: Record<string, string> = {
|
||||
'7d': 'Last 7 Days',
|
||||
'30d': 'Last 30 Days',
|
||||
'6m': 'Last 6 Months',
|
||||
'1y': 'Last 1 Year',
|
||||
};
|
||||
|
||||
// CSV Report Generator
|
||||
const handleExportCsv = () => {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('ETHIOPIAN MARITIME AUTHORITY - OPERATIONS DASHBOARD REPORT');
|
||||
lines.push(`Generated At,${new Date().toISOString()}`);
|
||||
lines.push(`Time Window,${periodLabelMap[period] ?? period}`);
|
||||
lines.push('');
|
||||
|
||||
// Section 1: Executive KPIs
|
||||
lines.push('--- EXECUTIVE KPIS ---');
|
||||
lines.push('Metric,Value');
|
||||
lines.push(`Awaiting Claim (Unclaimed Pool),${unclaimedCount}`);
|
||||
lines.push(`Assigned to Me,${assignedToMeCount}`);
|
||||
lines.push(`In Review (All Staff),${inProgressTotalCount}`);
|
||||
lines.push(`Action Required (Applicant Edits),${needsApplicantCount}`);
|
||||
lines.push(`Awaiting Payment,${awaitingPaymentCount}`);
|
||||
lines.push(`SLA Overdue,${overdueSlaCount}`);
|
||||
lines.push(`SLA Compliance Rate,${analytics?.kpis.slaComplianceRate ?? 100}%`);
|
||||
lines.push(`Active Registered Licences,${activeLicensesCount}`);
|
||||
lines.push(`Total Settled Revenue (ETB),${totalRevenue}`);
|
||||
lines.push('');
|
||||
|
||||
// Section 2: Department Summary
|
||||
lines.push('--- DEPARTMENT WORKLOADS ---');
|
||||
lines.push('Department / Service,Pending Count,Total Count');
|
||||
lines.push(
|
||||
`Seafarer Registrations,${seafarerRegCount ?? 0},${analytics?.departmentSummary.seafarerRegistrations.total ?? 0}`,
|
||||
);
|
||||
lines.push(
|
||||
`Seaman Books,${seamanBookCount ?? 0},${analytics?.departmentSummary.seamanBooks.total ?? 0}`,
|
||||
);
|
||||
lines.push(
|
||||
`Basic Training (BTC),${btcCount ?? 0},${analytics?.departmentSummary.btc.total ?? 0}`,
|
||||
);
|
||||
lines.push(
|
||||
`Vessel Registry,${analytics?.departmentSummary.vessels.pending ?? 0},${vesselCount ?? 0}`,
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// Section 3: Time Series
|
||||
if (analytics?.monthlyTrend && analytics.monthlyTrend.length > 0) {
|
||||
lines.push('--- INTAKE & DECISIONS TREND ---');
|
||||
lines.push('Period,Submitted,Approved / Issued,Rejected,Revenue (ETB)');
|
||||
for (const row of analytics.monthlyTrend) {
|
||||
lines.push(
|
||||
`"${row.month} ${row.year}",${row.submitted},${row.approved},${row.rejected},${row.revenue}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Section 4: Category Breakdown
|
||||
if (analytics?.categoryBreakdown && analytics.categoryBreakdown.length > 0) {
|
||||
lines.push('--- CATEGORY BREAKDOWN ---');
|
||||
lines.push('Category,Applications Count,Percentage');
|
||||
for (const cat of analytics.categoryBreakdown) {
|
||||
lines.push(`"${cat.category}",${cat.count},${cat.percentage}%`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Section 5: Urgent Applications
|
||||
if (urgentApps.length > 0) {
|
||||
lines.push('--- SLA PRIORITY WORKLIST ---');
|
||||
lines.push(
|
||||
'Application Number,Company / Applicant,License Type,Category,Status,Hours Remaining / Overdue',
|
||||
);
|
||||
for (const app of urgentApps) {
|
||||
lines.push(
|
||||
`"${app.applicationNumber}","${app.companyName ?? 'Applicant'}","${app.licenseTypeName}","${app.category}","${app.isOverdue ? 'OVERDUE' : 'AT RISK'}","${app.hoursLeft}h"`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const csvContent = lines.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`EMA-Operations-Dashboard-${period}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notify.success('Executive dashboard CSV report generated and downloaded.');
|
||||
};
|
||||
|
||||
const isInitialLoading = queue.isLoading && analyticsQuery.isLoading;
|
||||
if (isInitialLoading) {
|
||||
return <PageLoader label="Loading Backoffice Operations Dashboard…" height={450} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* Header & Quick Action Jump Bar */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<PageHeader
|
||||
title="Operations Dashboard"
|
||||
subtitle="Central administrative hub for review queues, turnaround SLAs, and departmental operations."
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{/* Time Window Selector */}
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
data={[
|
||||
{ label: '7 Days', value: '7d' },
|
||||
{ label: '30 Days', value: '30d' },
|
||||
{ label: '6 Months', value: '6m' },
|
||||
{ label: '1 Year', value: '1y' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip label="Export full dashboard summary as CSV">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconDownload size={15} />}
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Refresh all live data">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={handleRefreshAll}
|
||||
loading={analyticsQuery.isFetching || queue.isFetching}
|
||||
>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
leftSection={<IconInbox size={16} />}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Licence Queue
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
leftSection={<IconUserCheck size={16} />}
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
>
|
||||
Seafarers
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="cyan"
|
||||
size="sm"
|
||||
leftSection={<IconShip size={16} />}
|
||||
onClick={() => navigate('/vessel-registration-queue')}
|
||||
>
|
||||
Vessels
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* SLA Alert Banner if work has breached deadline */}
|
||||
{overdueSlaCount > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<IconAlertTriangle size={20} />}
|
||||
title="Attention: SLA Turnaround Threshold Exceeded"
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<Text size="sm">
|
||||
<Text span fw={700}>
|
||||
{overdueSlaCount} {overdueSlaCount === 1 ? 'application' : 'applications'}
|
||||
</Text>{' '}
|
||||
have exceeded the official service level agreement timeframe and require immediate officer action.
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Review Overdue Work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Primary Executive KPI Tiles (Row of 6) */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 6 }} spacing="md">
|
||||
<StatTile
|
||||
label="Awaiting claim"
|
||||
value={unclaimed.length}
|
||||
hint="Licence applications nobody has picked up"
|
||||
label="Awaiting Claim"
|
||||
value={unclaimedCount}
|
||||
hint="Unclaimed pool in queue"
|
||||
icon={IconInbox}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Assigned to me"
|
||||
value={inProgress.length}
|
||||
hint="Your open licence reviews"
|
||||
label="Assigned to Me"
|
||||
value={assignedToMeCount}
|
||||
hint="Your claimed active reviews"
|
||||
icon={IconFileText}
|
||||
tone="neutral"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Needs applicant action"
|
||||
value={needsApplicant}
|
||||
hint="Returned for corrections"
|
||||
label="In Review (All)"
|
||||
value={inProgressTotalCount}
|
||||
hint="Under review across all staff"
|
||||
icon={IconUsers}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Action Required"
|
||||
value={needsApplicantCount}
|
||||
hint="Returned for applicant edits"
|
||||
icon={IconAlertTriangle}
|
||||
tone="pending"
|
||||
/>
|
||||
<StatTile
|
||||
label="Awaiting payment"
|
||||
value={awaitingPayment}
|
||||
hint="Approved, fee not yet settled"
|
||||
label="Awaiting Payment"
|
||||
value={awaitingPaymentCount}
|
||||
hint="Approved, fee unsettled"
|
||||
icon={IconCreditCard}
|
||||
tone="warning"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
||||
it instead of stretching each tile to half the page. */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Seafarer registrations"
|
||||
value={show(countOf(registrations))}
|
||||
hint="Submitted, awaiting review"
|
||||
icon={IconUserCheck}
|
||||
tone="info"
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Seaman books"
|
||||
value={show(countOf(seamanBooks))}
|
||||
hint="Released, awaiting payment"
|
||||
icon={IconCreditCard}
|
||||
tone="pending"
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
label="SLA Overdue"
|
||||
value={overdueSlaCount}
|
||||
hint={overdueSlaCount > 0 ? 'Urgent attention required' : 'All within SLA window'}
|
||||
icon={IconClockExclamation}
|
||||
tone={overdueSlaCount > 0 ? 'danger' : 'neutral'}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Multi-Department Operations & Cross-Functional Strip */}
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={24} radius="sm" variant="light" color="blue">
|
||||
<IconFolders size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Cross-Department Workloads & Revenue
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Live pipeline counts across Maritime Authority desks
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 5 }} spacing="md" mt="sm">
|
||||
{/* Dept 1: Seafarer Registrations */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
transition: 'transform 120ms ease',
|
||||
}}
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Seafarer Regs
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="teal">
|
||||
<IconUserCheck size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="teal">
|
||||
{show(seafarerRegCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted for review
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 2: Seaman Books */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Seaman Books
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="indigo">
|
||||
<IconCertificate size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="indigo">
|
||||
{show(seamanBookCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Pending fee & issuance
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 3: Basic Training Certificates */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => navigate('/btc-queue')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
BTC Training
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="blue">
|
||||
<IconCertificate size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="blue">
|
||||
{show(btcCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Certificates in progress
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 4: Vessel Registry */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => navigate('/vessel-registration-queue')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Vessel Registry
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="cyan">
|
||||
<IconShip size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="cyan">
|
||||
{show(vesselCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Vessels registered
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 5: Settled Fees / Revenue */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Settled Fees
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="green">
|
||||
<IconCash size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} c="green" lineClamp={1}>
|
||||
{formattedRevenue}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Collections to date
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
{/* Interactive Visualizations & Charts with Selected Period Label */}
|
||||
<DashboardCharts
|
||||
analytics={analytics}
|
||||
periodLabel={periodLabelMap[period] ?? period}
|
||||
fallbackStatusDistribution={[
|
||||
{ name: 'Awaiting Claim', value: unclaimed.length, color: '#339af0' },
|
||||
{ name: 'Assigned to Me', value: inProgress.length, color: '#4c6ef5' },
|
||||
{ name: 'Action Required', value: fallbackNeedsApplicant, color: '#fab005' },
|
||||
{ name: 'Awaiting Payment', value: fallbackAwaitingPayment, color: '#fd7e14' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Operational Worklists & Actionable Queues */}
|
||||
<Grid gutter="lg">
|
||||
{/* Main Worklist: Oldest Unclaimed Pool or SLA Critical */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<AdvancedTable<LicenseApplication>
|
||||
title="Awaiting claim — oldest first"
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||
refresh={queue.refetch}
|
||||
isLoading={queue.isFetching}
|
||||
emptyText="Nothing waiting to be claimed."
|
||||
toolbar={
|
||||
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||
Open queue
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
<Card withBorder radius="lg" p="md" h="100%">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="space-between" mb="md" wrap="nowrap">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="unclaimed"
|
||||
leftSection={<IconInbox size={15} />}
|
||||
>
|
||||
Unclaimed Work Pool ({byAge.length})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="sla"
|
||||
leftSection={<IconClock size={15} />}
|
||||
color={urgentApps.length > 0 ? 'red' : 'gray'}
|
||||
>
|
||||
SLA Priority ({urgentApps.length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Anchor
|
||||
size="sm"
|
||||
c="blue"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
View Full Queue <IconChevronRight size={12} />
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Tabs.Panel value="unclaimed">
|
||||
<AdvancedTable<LicenseApplication>
|
||||
title="Awaiting Claim — Oldest First"
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={pagedUnclaimed.rows}
|
||||
itemCount={pagedUnclaimed.itemCount}
|
||||
pageIndex={pagedUnclaimed.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||
refresh={queue.refetch}
|
||||
isLoading={queue.isFetching}
|
||||
emptyText="No applications currently waiting to be claimed."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sla">
|
||||
{urgentApps.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
All applications are safely within their service level agreement deadlines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{urgentApps.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: item.isOverdue
|
||||
? 'var(--mantine-color-red-outline)'
|
||||
: 'var(--mantine-color-yellow-outline)',
|
||||
}}
|
||||
onClick={() => navigate(`/licence-review/${item.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={28}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={item.isOverdue ? 'red' : 'yellow'}
|
||||
>
|
||||
<IconClockExclamation size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="sm" c="blue">
|
||||
{item.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.companyName ?? 'Applicant'} • {item.licenseTypeName}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={item.isOverdue ? 'red' : 'yellow'}
|
||||
size="sm"
|
||||
>
|
||||
{item.isOverdue
|
||||
? `Overdue by ${Math.abs(item.hoursLeft)}h`
|
||||
: `${item.hoursLeft}h remaining`}
|
||||
</Badge>
|
||||
<IconChevronRight size={14} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Side Panel: Longest Waiting & Fast Directory */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
Longest waiting
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Unclaimed applications, by how long they have sat.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{byAge.slice(0, 5).map((app) => (
|
||||
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Anchor
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
>
|
||||
{app.applicationNumber}
|
||||
</Anchor>
|
||||
<WaitingFor
|
||||
since={app.submittedAt ?? app.createdAt}
|
||||
slaDays={
|
||||
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{byAge.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing waiting.
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Longest Waiting Applications Card */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Longest Waiting Applications
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<IconClock size={16} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Unclaimed applications sitting in queue by age.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{byAge.slice(0, 5).map((app) => (
|
||||
<Group
|
||||
key={app.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
>
|
||||
<Anchor
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
fw={500}
|
||||
>
|
||||
{app.applicationNumber}
|
||||
</Anchor>
|
||||
<WaitingFor
|
||||
since={app.submittedAt ?? app.createdAt}
|
||||
slaDays={
|
||||
app.licenseType?.slaHours
|
||||
? app.licenseType.slaHours / 24
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{byAge.length === 0 && (
|
||||
<Text size="sm" c="dimmed" py="xs">
|
||||
Nothing waiting to be claimed.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Quick Operations Navigation Directory */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ flexGrow: 1 }}>
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
Quick Authority Desks
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Direct access to core backoffice review workspaces.
|
||||
</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconInbox size={16} color="var(--mantine-color-blue-filled)" />
|
||||
<Text size="sm">Licence Review Queue</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/licence-register')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconCertificate size={16} color="var(--mantine-color-teal-filled)" />
|
||||
<Text size="sm">Official Licence Register</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconUserCheck size={16} color="var(--mantine-color-indigo-filled)" />
|
||||
<Text size="sm">Seafarer Registrations</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconCreditCard size={16} color="var(--mantine-color-orange-filled)" />
|
||||
<Text size="sm">Seaman Books & BTC</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/vessel-registration-queue')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconShip size={16} color="var(--mantine-color-cyan-filled)" />
|
||||
<Text size="sm">Vessel Register Queue</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/exams')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconFolders size={16} color="var(--mantine-color-grape-filled)" />
|
||||
<Text size="sm">Examinations & CoC</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
@@ -13,7 +13,13 @@ import type {
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
GradingSheet,
|
||||
AddExamQuestionsPayload,
|
||||
CreateExamQuestionPayload,
|
||||
ImportExamQuestionsPayload,
|
||||
QuestionImportReport,
|
||||
ExamWaitMetrics,
|
||||
} from '../types/exam';
|
||||
import type { Question } from '../../question/types/question';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
@@ -60,6 +66,48 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Question management from the exam page ---------------------------
|
||||
/** Append approved bank items to the paper, keeping what is already on it. */
|
||||
addExamQuestions: builder.mutation<Exam, AddExamQuestionsPayload>({
|
||||
query: ({ examId, questionIds }) => ({
|
||||
url: `/exams/${examId}/questions/add`,
|
||||
method: 'POST',
|
||||
body: { questionIds },
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Author a question under the exam's subject and put it on the paper in one call. */
|
||||
createExamQuestion: builder.mutation<Question, CreateExamQuestionPayload>({
|
||||
query: ({ examId, ...body }) => ({
|
||||
url: `/exams/${examId}/questions/new`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/**
|
||||
* Excel import. `dryRun` validates and previews without writing; the real
|
||||
* import runs the same validation and is all-or-nothing on the server.
|
||||
*/
|
||||
importExamQuestions: builder.mutation<QuestionImportReport, ImportExamQuestionsPayload>({
|
||||
query: ({ examId, file, dryRun }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
// No Content-Type header: fetch sets it with the multipart boundary.
|
||||
return {
|
||||
url: `/exams/${examId}/questions/import?dryRun=${dryRun ? 'true' : 'false'}`,
|
||||
method: 'POST',
|
||||
body,
|
||||
};
|
||||
},
|
||||
// A dry run changes nothing, so the paper does not need refetching.
|
||||
invalidatesTags: (_result, error, { dryRun }) => (error || dryRun ? [] : ['Api']),
|
||||
}),
|
||||
/** Read-only analytics over the session's own timestamps. */
|
||||
getExamWaitMetrics: builder.query<ExamWaitMetrics, string>({
|
||||
query: (examId) => `/exams/${examId}/wait-metrics`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
// --- Candidates and attendance (US-EXAM-007/009) ---------------------
|
||||
getExamRegistrations: builder.query<ExamRegistration[], string>({
|
||||
query: (examId) => `/exams/${examId}/registrations`,
|
||||
@@ -123,6 +171,10 @@ export const {
|
||||
useDeleteExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useAddExamQuestionsMutation,
|
||||
useCreateExamQuestionMutation,
|
||||
useImportExamQuestionsMutation,
|
||||
useGetExamWaitMetricsQuery,
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useGetExamIncidentsQuery,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Badge, Menu, Text } from '@mantine/core';
|
||||
import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import { ActionIcon, Badge, Group, Menu, Text, Tooltip } from '@mantine/core';
|
||||
import { IconDotsVertical, IconLock, IconRefresh, IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -78,13 +78,59 @@ export function examCandidateColumns(
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
// What the paper came to, if it has been marked — so the invigilator
|
||||
// and the marking officer see at a glance whose result already exists
|
||||
// (and whether the engine produced it, in which case it is locked).
|
||||
header: t('exam.candidates.result'),
|
||||
cell: ({ row }) => {
|
||||
const result = row.original.result;
|
||||
if (!result) {
|
||||
return (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('exam.candidates.noResult')}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const outcome = t(`exam.candidates.outcome.${result.status}`);
|
||||
const review = t(`result.review.${result.reviewStatus}`, result.reviewStatus);
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
result.autoGraded
|
||||
? t('exam.candidates.engineMarked', { review })
|
||||
: t('exam.candidates.examinerMarked', { review })
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={result.status === 'PASSED' ? 'teal' : 'red'}
|
||||
leftSection={result.autoGraded ? <IconLock size={10} /> : undefined}
|
||||
>
|
||||
{outcome} · {result.totalScore}
|
||||
</Badge>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{review}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
|
||||
// Regrading creates a result; once one exists the API refuses
|
||||
// (result_already_recorded), so the action is not offered.
|
||||
const canRegrade =
|
||||
(attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED') &&
|
||||
!row.original.result;
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { ModalFooter, notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useCreateExamQuestionMutation } from '../../api/exam-api';
|
||||
import type { Exam, QuestionForm } from '../../types/exam';
|
||||
import { describeExamQuestionError } from './errors';
|
||||
|
||||
type DraftOption = { textEn: string; textAm: string; isCorrect: boolean };
|
||||
|
||||
const BLANK: DraftOption[] = [
|
||||
{ textEn: '', textAm: '', isCorrect: false },
|
||||
{ textEn: '', textAm: '', isCorrect: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Add new question from scratch": authored under this exam's subject and
|
||||
* put on its paper in one call — the officer never leaves the exam or copies
|
||||
* an id. The item is a real bank question (options, answer key), reusable on
|
||||
* a later paper.
|
||||
*/
|
||||
export function ExamQuestionCreateModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [createQuestion, { isLoading }] = useCreateExamQuestionMutation();
|
||||
const [titleEn, setTitleEn] = useState('');
|
||||
const [titleAm, setTitleAm] = useState('');
|
||||
const [form, setForm] = useState<QuestionForm | null>(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE');
|
||||
const [points, setPoints] = useState<number>(1);
|
||||
const [options, setOptions] = useState<DraftOption[]>(BLANK);
|
||||
|
||||
const reset = () => {
|
||||
setTitleEn('');
|
||||
setTitleAm('');
|
||||
setForm(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE');
|
||||
setPoints(1);
|
||||
setOptions(BLANK);
|
||||
};
|
||||
const close = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateOption = (index: number, patch: Partial<DraftOption>) =>
|
||||
setOptions((current) => current.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
|
||||
// A mixed (BOTH) paper takes either form; otherwise the question must match.
|
||||
const formOptions = (exam.form === 'BOTH' ? ['ESSAY', 'CHOICE'] : [exam.form]).map((value) => ({
|
||||
value,
|
||||
label: t(`exam.formType.${value}`),
|
||||
}));
|
||||
|
||||
const submit = async () => {
|
||||
if (!titleEn.trim() || !form || !(points > 0)) {
|
||||
notify.error(t('exam.newQuestion.fillRequired'));
|
||||
return;
|
||||
}
|
||||
if (form === 'CHOICE') {
|
||||
if (options.length < 2) return void notify.error(t('exam.newQuestion.needTwo'));
|
||||
if (!options.some((o) => o.isCorrect)) return void notify.error(t('exam.newQuestion.needCorrect'));
|
||||
if (options.some((o) => !o.textEn.trim())) return void notify.error(t('exam.newQuestion.textRequired'));
|
||||
}
|
||||
try {
|
||||
await createQuestion({
|
||||
examId: exam.id,
|
||||
// The API requires Amharic; it falls back to the English text server-side
|
||||
// as well, but sending it explicitly keeps the request self-describing.
|
||||
title: { en: titleEn.trim(), am: titleAm.trim() || titleEn.trim() },
|
||||
form,
|
||||
points,
|
||||
options:
|
||||
form === 'CHOICE'
|
||||
? options.map((o) => ({
|
||||
text: { en: o.textEn.trim(), am: o.textAm.trim() || o.textEn.trim() },
|
||||
isCorrect: o.isCorrect,
|
||||
}))
|
||||
: undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('exam.newQuestion.created'));
|
||||
close();
|
||||
} catch (error) {
|
||||
notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error'))));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t('exam.newQuestion.title')} size="lg" radius="lg">
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed">{t('exam.newQuestion.hint')}</Text>
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.titleEn')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.titleAm')}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t('exam.newQuestion.form')}
|
||||
data={formOptions}
|
||||
value={form}
|
||||
onChange={(v) => setForm((v as QuestionForm) ?? null)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('exam.newQuestion.points')}
|
||||
value={points}
|
||||
onChange={(v) => setPoints(Number(v))}
|
||||
min={1}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
{form === 'CHOICE' && (
|
||||
<Stack gap="xs">
|
||||
<Text fz="sm" fw={500}>{t('exam.newQuestion.options')}</Text>
|
||||
{options.map((option, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.optionEn', { number: index + 1 })}
|
||||
value={option.textEn}
|
||||
onChange={(e) => updateOption(index, { textEn: e.currentTarget.value })}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t('exam.newQuestion.optionAm', { number: index + 1 })}
|
||||
value={option.textAm}
|
||||
onChange={(e) => updateOption(index, { textAm: e.currentTarget.value })}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('exam.newQuestion.correct')}
|
||||
checked={option.isCorrect}
|
||||
onChange={() => updateOption(index, { isCorrect: !option.isCorrect })}
|
||||
mb={6}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
mb={8}
|
||||
disabled={options.length <= 2}
|
||||
onClick={() => setOptions((current) => current.filter((_, i) => i !== index))}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
w="fit-content"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => setOptions((current) => [...current, { textEn: '', textAm: '', isCorrect: false }])}
|
||||
>
|
||||
{t('exam.newQuestion.addOption')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={close}>{t('exam.cancel')}</Button>
|
||||
<Button size="sm" loading={isLoading} onClick={submit}>{t('exam.newQuestion.create')}</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck, IconDownload, IconFileSpreadsheet, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { ModalFooter, notify } from '@ema-platform/ui';
|
||||
import { downloadAuthedFile, extractErrorMessage } from '@ema-platform/api';
|
||||
import { useImportExamQuestionsMutation } from '../../api/exam-api';
|
||||
import type { Exam, QuestionImportReport } from '../../types/exam';
|
||||
import { describeExamQuestionError, describeImportError } from './errors';
|
||||
|
||||
/**
|
||||
* "Import from Excel": upload → validate (a dry run on the server, which
|
||||
* reports every error at once) → preview → import. The real import runs the
|
||||
* same validation again and is all-or-nothing, so the preview the officer
|
||||
* confirmed is what lands on the paper, or nothing does.
|
||||
*/
|
||||
export function ExamQuestionImportModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [importQuestions, { isLoading }] = useImportExamQuestionsMutation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [report, setReport] = useState<QuestionImportReport | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setFile(null);
|
||||
setReport(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const outcome = await importQuestions({ examId: exam.id, file, dryRun }).unwrap();
|
||||
setReport(outcome);
|
||||
if (!dryRun && outcome.imported > 0) {
|
||||
notify.success(t('exam.import.imported', { count: outcome.imported }));
|
||||
close();
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error'))));
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
try {
|
||||
await downloadAuthedFile('/exams/questions/import-template', 'exam-questions-template.xlsx');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const valid = report !== null && report.errors.length === 0 && report.rows.length > 0;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t('exam.import.title')} size="xl" radius="lg">
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('exam.import.hint')}{' '}
|
||||
<Anchor fz="sm" onClick={downloadTemplate}>
|
||||
<IconDownload size={12} style={{ verticalAlign: 'middle' }} /> {t('exam.import.template')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
<FileInput
|
||||
label={t('exam.import.file')}
|
||||
placeholder="questions.xlsx"
|
||||
accept=".xlsx,.xlsm"
|
||||
leftSection={<IconFileSpreadsheet size={16} />}
|
||||
value={file}
|
||||
onChange={(next) => {
|
||||
setFile(next);
|
||||
setReport(null);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{report && report.errors.length > 0 && (
|
||||
<Alert color="red" icon={<IconInfoCircle size={16} />} title={t('exam.import.errors', { count: report.errors.length })}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<Table fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.import.row')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.column')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.problem')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{report.errors.map((error, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>{error.row || '—'}</Table.Td>
|
||||
<Table.Td>{error.column ?? '—'}</Table.Td>
|
||||
<Table.Td>{describeImportError(t, error.message)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
<Text fz="xs" mt="xs">{t('exam.import.nothingImported')}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{report && report.rows.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text fz="sm" fw={600}>{t('exam.import.preview', { count: report.rows.length })}</Text>
|
||||
{valid && (
|
||||
<Badge color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
|
||||
{t('exam.import.valid')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Table striped fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.import.row')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.question')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.type')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.points')}</Table.Th>
|
||||
<Table.Th>{t('exam.import.options')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{report.rows.map((row) => (
|
||||
<Table.Tr key={row.row}>
|
||||
<Table.Td>{row.row}</Table.Td>
|
||||
<Table.Td><Text fz="xs" lineClamp={2}>{row.titleEn}</Text></Table.Td>
|
||||
<Table.Td>{t(`exam.formType.${row.form}`)}</Table.Td>
|
||||
<Table.Td>{row.points}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.options.length
|
||||
? row.options.map((o) => (o.correct ? `${o.letter}✓` : o.letter)).join(' ')
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={close}>{t('exam.cancel')}</Button>
|
||||
<Button variant="light" size="sm" disabled={!file} loading={isLoading && !valid} onClick={() => run(true)}>
|
||||
{t('exam.import.validate')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!valid} loading={isLoading && valid} onClick={() => run(false)}>
|
||||
{t('exam.import.import')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconSearch } from '@tabler/icons-react';
|
||||
import { ModalFooter, notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetQuestionsQuery } from '../../../question/api/question-api';
|
||||
import { useAddExamQuestionsMutation } from '../../api/exam-api';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { describeExamQuestionError } from './errors';
|
||||
|
||||
/**
|
||||
* "Create question from question bank": pick approved items for this exam's
|
||||
* subject and add them to the paper as it stands. Items already on the paper
|
||||
* are not offered — the bank is reusable, the paper holds each item once.
|
||||
*/
|
||||
export function QuestionBankPickerModal({
|
||||
exam,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
exam: Exam;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: qRes, isFetching } = useGetQuestionsQuery(undefined, { skip: !opened });
|
||||
const [addQuestions, { isLoading }] = useAddExamQuestionsMutation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const onPaper = useMemo(
|
||||
() => new Set((exam.questions ?? []).map((q) => q.id)),
|
||||
[exam.questions],
|
||||
);
|
||||
|
||||
// Only approved bank items of this subject can go on a paper (US-EXAM-003),
|
||||
// and the form has to fit the session unless it is a mixed (BOTH) paper.
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
(qRes?.items ?? []).filter(
|
||||
(q) =>
|
||||
q.certificationId === exam.certificationId &&
|
||||
q.status === 'APPROVED' &&
|
||||
q.isActive &&
|
||||
(exam.form === 'BOTH' || q.form === exam.form) &&
|
||||
!onPaper.has(q.id),
|
||||
),
|
||||
[qRes, exam.certificationId, exam.form, onPaper],
|
||||
);
|
||||
|
||||
const visible = search
|
||||
? candidates.filter((q) =>
|
||||
`${q.title.en ?? ''} ${q.title.am ?? ''}`.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: candidates;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setSelected(new Set());
|
||||
setSearch('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
try {
|
||||
await addQuestions({ examId: exam.id, questionIds: [...selected] }).unwrap();
|
||||
notify.success(t('exam.bank.added', { count: selected.size }));
|
||||
close();
|
||||
} catch (error) {
|
||||
notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error'))));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t('exam.bank.title')} size="lg" radius="lg">
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed">{t('exam.bank.hint')}</Text>
|
||||
<TextInput
|
||||
placeholder={t('exam.bank.search')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
{!isFetching && candidates.length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>{t('exam.bank.empty')}</Alert>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={360}>
|
||||
<Stack gap={6}>
|
||||
{visible.map((q) => (
|
||||
<Checkbox
|
||||
key={q.id}
|
||||
checked={selected.has(q.id)}
|
||||
onChange={() => toggle(q.id)}
|
||||
label={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fz="sm" lineClamp={2}>{q.title[locale] || q.title.en}</Text>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`exam.formType.${q.form}`)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
<ModalFooter>
|
||||
<Button variant="default" size="sm" onClick={close}>{t('exam.cancel')}</Button>
|
||||
<Button size="sm" loading={isLoading} disabled={selected.size === 0} onClick={add}>
|
||||
{t('exam.bank.add', { count: selected.size })}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
/**
|
||||
* Error keys the question-management endpoints return, made readable. Keys
|
||||
* carry detail after a colon (`paper_cannot_reach_cutting_point:20/50`), so
|
||||
* the prefix is matched and the detail passed to the message.
|
||||
*/
|
||||
export function describeExamQuestionError(t: TFunction, key: string): string {
|
||||
const [code, detail = ''] = key.split(':');
|
||||
switch (code) {
|
||||
case 'paper_locked_after_registration':
|
||||
return t('exam.paperLocked');
|
||||
case 'paper_cannot_reach_cutting_point': {
|
||||
const [max, cuttingPoint] = detail.split('/');
|
||||
return t('exam.cannotReachCuttingPoint', { max, cuttingPoint });
|
||||
}
|
||||
case 'question_not_approved':
|
||||
return t('question.qc.onlyApprovedUsable');
|
||||
case 'question_subject_mismatch':
|
||||
return t('exam.questionErrors.subjectMismatch');
|
||||
case 'question_not_found':
|
||||
return t('exam.questionErrors.notFound');
|
||||
case 'options_required':
|
||||
return t('exam.newQuestion.needTwo');
|
||||
case 'at_least_one_correct_option_required':
|
||||
return t('exam.newQuestion.needCorrect');
|
||||
case 'invalid_points':
|
||||
return t('exam.newQuestion.fillRequired');
|
||||
case 'excel_file_required':
|
||||
case 'file_required':
|
||||
return t('exam.import.errorKeys.invalid_excel_file');
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/** Row-level import problems, as the validator names them. */
|
||||
export function describeImportError(t: TFunction, message: string): string {
|
||||
const [code, detail = ''] = message.split(':');
|
||||
const known = [
|
||||
'question_text_required',
|
||||
'invalid_question_type',
|
||||
'invalid_points',
|
||||
'options_required',
|
||||
'correct_answer_required',
|
||||
'correct_answer_invalid',
|
||||
'duplicate_in_file',
|
||||
'duplicate_in_bank',
|
||||
'missing_columns',
|
||||
'too_many_rows',
|
||||
'no_questions_in_file',
|
||||
'invalid_excel_file',
|
||||
];
|
||||
if (code === 'paper_cannot_reach_cutting_point') {
|
||||
const [max, cuttingPoint] = detail.split('/');
|
||||
return t('exam.cannotReachCuttingPoint', { max, cuttingPoint });
|
||||
}
|
||||
if (known.includes(code)) return t(`exam.import.errorKeys.${code}`, { detail });
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { QuestionBankPickerModal } from './QuestionBankPickerModal';
|
||||
export { ExamQuestionCreateModal } from './ExamQuestionCreateModal';
|
||||
export { ExamQuestionImportModal } from './ExamQuestionImportModal';
|
||||
export { describeExamQuestionError } from './errors';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Paper, SimpleGrid, Text, Title, Badge } from '@mantine/core';
|
||||
import { IconHourglass } from '@tabler/icons-react';
|
||||
import { useGetExamWaitMetricsQuery } from '../api/exam-api';
|
||||
import type { WaitStat } from '../types/exam';
|
||||
|
||||
function minutes(value: number | null, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
if (value === null) return '—';
|
||||
const abs = Math.abs(value);
|
||||
const label =
|
||||
abs >= 1440
|
||||
? t('exam.metrics.days', { value: Math.round((abs / 1440) * 10) / 10 })
|
||||
: abs >= 60
|
||||
? t('exam.metrics.hours', { value: Math.round((abs / 60) * 10) / 10 })
|
||||
: t('exam.metrics.minutes', { value: Math.round(abs * 10) / 10 });
|
||||
return value < 0 ? `−${label}` : label;
|
||||
}
|
||||
|
||||
function StatCard({ label, stat, t }: { label: string; stat: WaitStat; t: (key: string, options?: Record<string, unknown>) => string }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz={22} fw={700} mt={4}>{minutes(stat.averageMinutes, t)}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('exam.metrics.average')}</Text>
|
||||
<Group gap="md" mt="xs">
|
||||
<Text fz="xs">{t('exam.metrics.min')}: <b>{minutes(stat.minMinutes, t)}</b></Text>
|
||||
<Text fz="xs">{t('exam.metrics.max')}: <b>{minutes(stat.maxMinutes, t)}</b></Text>
|
||||
<Text fz="xs">{t('exam.metrics.count')}: <b>{stat.count}</b></Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exam wait metrics — how long candidates waited at each step up to the
|
||||
* sitting, read off the registration, attendance and attempt timestamps
|
||||
* the workflow already writes. Analytics only: nothing here can change a
|
||||
* registration, an attendance ruling, a result or a certificate.
|
||||
*/
|
||||
export function ExamWaitMetricsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isError } = useGetExamWaitMetricsQuery(examId);
|
||||
if (isError || !data) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<IconHourglass size={18} />
|
||||
<Title order={5}>{t('exam.metrics.section')}</Title>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="gray">{t('exam.metrics.candidates', { count: data.candidateCount })}</Badge>
|
||||
<Badge variant="light" color="teal">{t('exam.metrics.attended', { count: data.attendedCount })}</Badge>
|
||||
<Badge variant="light" color="blue">{t('exam.metrics.started', { count: data.startedCount })}</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="md">{t('exam.metrics.hint')}</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<StatCard label={t('exam.metrics.registrationToScheduled')} stat={data.registrationToScheduled} t={t} />
|
||||
<StatCard label={t('exam.metrics.scheduledToAttendance')} stat={data.scheduledToAttendance} t={t} />
|
||||
<StatCard label={t('exam.metrics.attendanceToExamStart')} stat={data.attendanceToExamStart} t={t} />
|
||||
<StatCard label={t('exam.metrics.scheduledToExamStart')} stat={data.scheduledToExamStart} t={t} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Tooltip,
|
||||
Menu,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -40,6 +41,11 @@ import {
|
||||
IconUser,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconFileSpreadsheet,
|
||||
IconPencilPlus,
|
||||
IconListCheck,
|
||||
IconChevronDown,
|
||||
} from '@tabler/icons-react';
|
||||
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
@@ -57,6 +63,12 @@ import { QuestionAssigner } from '../components/QuestionAssigner';
|
||||
import { RecordResultModal } from '../../result/components/RecordResultModal';
|
||||
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
|
||||
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import { ExamWaitMetricsPanel } from '../components/ExamWaitMetricsPanel';
|
||||
import {
|
||||
ExamQuestionCreateModal,
|
||||
ExamQuestionImportModal,
|
||||
QuestionBankPickerModal,
|
||||
} from '../components/ExamQuestionActions';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
@@ -66,7 +78,6 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = {
|
||||
@@ -109,6 +120,11 @@ export function ExamDetailPage() {
|
||||
useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] =
|
||||
useDisclosure(false);
|
||||
// The three contextual ways to populate this exam's paper without leaving it.
|
||||
const [bankOpened, { open: openBank, close: closeBank }] = useDisclosure(false);
|
||||
const [importOpened, { open: openImport, close: closeImport }] = useDisclosure(false);
|
||||
const [newQuestionOpened, { open: openNewQuestion, close: closeNewQuestion }] =
|
||||
useDisclosure(false);
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
@@ -415,6 +431,14 @@ export function ExamDetailPage() {
|
||||
/>
|
||||
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
|
||||
<InfoRow label={t("exam.detail.date")} value={exam.date} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.window")}
|
||||
value={
|
||||
exam.startTime || exam.endTime
|
||||
? `${exam.startTime?.slice(0, 5) ?? "00:00"} – ${exam.endTime?.slice(0, 5) ?? "23:59"}`
|
||||
: t("exam.detail.allDay")
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.administration")}
|
||||
value={t(`exam.admin.${exam.administrationMethod}`)}
|
||||
@@ -485,15 +509,37 @@ export function ExamDetailPage() {
|
||||
{/* Wrapped: a disabled Mantine Button fires no pointer events,
|
||||
so the tooltip needs an enabled element to hang off. */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
disabled={paperLocked}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
{/* One place to add questions from this exam's own page:
|
||||
the bank, an Excel sheet, or a brand-new item — every
|
||||
path lands the question on this paper. */}
|
||||
<Menu shadow="md" width={240} position="bottom-end" disabled={paperLocked}>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
disabled={paperLocked}
|
||||
>
|
||||
{t("exam.questionsMenu.add")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconDatabase size={14} />} onClick={openBank}>
|
||||
{t("exam.questionsMenu.fromBank")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconFileSpreadsheet size={14} />} onClick={openImport}>
|
||||
{t("exam.questionsMenu.importExcel")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPencilPlus size={14} />} onClick={openNewQuestion}>
|
||||
{t("exam.questionsMenu.fromScratch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconListCheck size={14} />} onClick={openAssignModal}>
|
||||
{t("exam.questionsMenu.managePaper")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -539,8 +585,13 @@ export function ExamDetailPage() {
|
||||
{/* Exam-day operations: who sat the paper, and what went wrong */}
|
||||
<ExamCandidatesPanel examId={exam.id} />
|
||||
<ExamIncidentsPanel examId={exam.id} />
|
||||
{/* Analytics over the same records — read-only, never a step in the workflow */}
|
||||
<ExamWaitMetricsPanel examId={exam.id} />
|
||||
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
<QuestionBankPickerModal exam={exam} opened={bankOpened} onClose={closeBank} />
|
||||
<ExamQuestionImportModal exam={exam} opened={importOpened} onClose={closeImport} />
|
||||
<ExamQuestionCreateModal exam={exam} opened={newQuestionOpened} onClose={closeNewQuestion} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal
|
||||
|
||||
@@ -11,7 +11,6 @@ const STATUS_TONE: Record<string, StatusTone> = {
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'danger',
|
||||
POSTPONED: 'pending',
|
||||
PUBLISHED: 'success',
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
@@ -41,7 +40,18 @@ export function examColumns(
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||
cell: ({ row }) => {
|
||||
const start = row.original.startTime?.slice(0, 5);
|
||||
const end = row.original.endTime?.slice(0, 5);
|
||||
return (
|
||||
<Text fz="sm">
|
||||
{row.original.date}
|
||||
{start || end ? (
|
||||
<Text span fz="xs" c="dimmed">{` · ${start ?? "00:00"} – ${end ?? "23:59"}`}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.type"),
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import { useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||
import { extractErrorMessage, useGetRanksQuery, useLocalized } from "@ema-platform/api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
@@ -78,6 +78,8 @@ function ExamForm({
|
||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? "");
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? "");
|
||||
const [date, setDate] = useState(editing?.date ?? "");
|
||||
const [startTime, setStartTime] = useState(editing?.startTime?.slice(0, 5) ?? "");
|
||||
const [endTime, setEndTime] = useState(editing?.endTime?.slice(0, 5) ?? "");
|
||||
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
||||
@@ -113,6 +115,8 @@ function ExamForm({
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
venue,
|
||||
type,
|
||||
form,
|
||||
@@ -150,6 +154,8 @@ function ExamForm({
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
@@ -239,6 +245,26 @@ function ExamForm({
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
{/* The session window. The backend refuses to start an attempt
|
||||
before startTime (exam_not_started) and after endTime, on its
|
||||
own clock — this is only where the officer sets it. */}
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
label={t("exam.form.startTime")}
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.endTime")}
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{t("exam.form.windowHint")}</Text>
|
||||
<TextInput
|
||||
label={t("exam.form.venue")}
|
||||
placeholder={t("exam.form.venuePlaceholder")}
|
||||
@@ -375,7 +401,6 @@ function ExamForm({
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
@@ -408,6 +433,14 @@ function ExamForm({
|
||||
<ReviewRow label={t("exam.form.titleEn")} value={titleEn} />
|
||||
<ReviewRow label={t("exam.form.titleAm")} value={titleAm} />
|
||||
<ReviewRow label={t("exam.form.examDate")} value={date} />
|
||||
<ReviewRow
|
||||
label={t("exam.detail.window")}
|
||||
value={
|
||||
startTime || endTime
|
||||
? `${startTime || "00:00"} – ${endTime || "23:59"}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ReviewRow label={t("exam.form.venue")} value={venue} />
|
||||
<ReviewRow
|
||||
label={t("exam.detail.timeAllowed")}
|
||||
@@ -522,6 +555,17 @@ export function ExamPage() {
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const describeError = (error: unknown) => {
|
||||
const key = extractErrorMessage(error, "");
|
||||
if (key === "exam_window_invalid") return notify.error(t("exam.form.windowInvalid"));
|
||||
if (key.startsWith("exam_scoring_locked_by_results")) {
|
||||
return notify.error(
|
||||
t("exam.errors.scoringLocked", { count: Number(key.split(":")[1] ?? 0) }),
|
||||
);
|
||||
}
|
||||
return handleError(error);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any, isEdit: boolean) => {
|
||||
const payload: any = {
|
||||
certificationId: values.certificationId,
|
||||
@@ -531,6 +575,8 @@ export function ExamPage() {
|
||||
? { en: values.directionEn, am: values.directionAm }
|
||||
: undefined,
|
||||
date: values.date,
|
||||
startTime: values.startTime || null,
|
||||
endTime: values.endTime || null,
|
||||
givenTime: {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
@@ -556,7 +602,7 @@ export function ExamPage() {
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
describeError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -702,7 +748,6 @@ export function ExamPage() {
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={pendingStatus}
|
||||
onChange={(value) => setPendingStatus(value as Exam["status"])}
|
||||
|
||||
@@ -16,6 +16,8 @@ const complete: ExamFormValues = {
|
||||
directionEn: '',
|
||||
directionAm: '',
|
||||
date: '2026-09-10',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
venue: 'Addis Ababa',
|
||||
type: 'WRITTEN',
|
||||
form: 'CHOICE',
|
||||
@@ -86,3 +88,33 @@ describe('exam form step validation', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The session window is optional on both ends, but when both are given the
|
||||
* exam cannot close before it opens — the same rule the API enforces as
|
||||
* exam_window_invalid, reported here on the step that owns the fields.
|
||||
*/
|
||||
describe('session window', () => {
|
||||
it('accepts no window, a start alone, an end alone, and a well-ordered pair', () => {
|
||||
expect(validateBasic(complete)).toBeNull();
|
||||
expect(validateBasic({ ...complete, startTime: '10:00' })).toBeNull();
|
||||
expect(validateBasic({ ...complete, endTime: '12:00' })).toBeNull();
|
||||
expect(validateBasic({ ...complete, startTime: '10:00', endTime: '12:00' })).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an end at or before the start', () => {
|
||||
expect(validateBasic({ ...complete, startTime: '12:00', endTime: '10:00' })).toBe(
|
||||
'exam.form.windowInvalid',
|
||||
);
|
||||
expect(validateBasic({ ...complete, startTime: '10:00', endTime: '10:00' })).toBe(
|
||||
'exam.form.windowInvalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('lands the user back on Basic Info to fix it', () => {
|
||||
expect(stepOfError('exam.form.windowInvalid')).toBe(0);
|
||||
expect(validateAll({ ...complete, startTime: '12:00', endTime: '10:00' })).toBe(
|
||||
'exam.form.windowInvalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface ExamFormValues {
|
||||
directionEn: string;
|
||||
directionAm: string;
|
||||
date: string;
|
||||
/** `HH:MM` or empty — the session window, optional on both ends. */
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
venue: string;
|
||||
type: string | null;
|
||||
form: string | null;
|
||||
@@ -32,6 +35,12 @@ export function validateBasic(v: ExamFormValues): string | null {
|
||||
if ((v.directionEn || v.directionAm) && !(v.directionEn && v.directionAm)) {
|
||||
return 'exam.form.directionBothLanguages';
|
||||
}
|
||||
// A session cannot close before it opens. Same rule the API applies
|
||||
// (exam_window_invalid), caught here so it is reported on the step that
|
||||
// owns the fields.
|
||||
if (v.startTime && v.endTime && v.endTime <= v.startTime) {
|
||||
return 'exam.form.windowInvalid';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,13 @@ export type ExamType = "WRITTEN" | "ORAL";
|
||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||
/**
|
||||
* The session's own lifecycle. No PUBLISHED: whether a candidate can see
|
||||
* their mark is that candidate's Result (`reviewStatus`/`publishedAt`), never
|
||||
* a property of the exam every other candidate shares. Mirrors EExamStatus.
|
||||
*/
|
||||
export type ExamStatus =
|
||||
"PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED" | "PUBLISHED";
|
||||
"PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED";
|
||||
|
||||
/** Only populated when the exam is fetched with `?i=questions,questions.options`. */
|
||||
export interface QuestionOptionBrief {
|
||||
@@ -39,6 +44,10 @@ export interface Exam {
|
||||
title: LocalePair;
|
||||
direction: LocalePair | null;
|
||||
date: string;
|
||||
/** `HH:MM[:SS]` on `date`, authority timezone; null means the session opens at the start of the day. */
|
||||
startTime: string | null;
|
||||
/** `HH:MM[:SS]` on `date`; null means the end of the day. Only bounds *starting* an attempt. */
|
||||
endTime: string | null;
|
||||
givenTime: EstimatedTime | null;
|
||||
type: ExamType;
|
||||
form: ExamForm;
|
||||
@@ -63,6 +72,8 @@ export interface CreateExamPayload {
|
||||
title: LocalePair;
|
||||
direction?: LocalePair;
|
||||
date: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
givenTime: EstimatedTime;
|
||||
type: ExamType;
|
||||
form: ExamForm;
|
||||
@@ -79,6 +90,8 @@ export interface UpdateExamPayload {
|
||||
title?: LocalePair;
|
||||
direction?: LocalePair;
|
||||
date?: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
givenTime?: EstimatedTime;
|
||||
type?: ExamType;
|
||||
form?: ExamForm;
|
||||
@@ -128,8 +141,38 @@ export interface ExamRegistration {
|
||||
id: string;
|
||||
status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED";
|
||||
} | null;
|
||||
/**
|
||||
* Where the sitting stands, derived server-side from this row, the attempt
|
||||
* and the published mark — the same reading the COC queue/detail and the
|
||||
* applicant's portal show.
|
||||
*/
|
||||
examState?: RegistrationExamState;
|
||||
/**
|
||||
* The mark already on file for this candidate, at any review stage. Null
|
||||
* until someone (or the engine) has marked the paper — which is what
|
||||
* decides whether the marking screen may still offer this candidate.
|
||||
*/
|
||||
result?: {
|
||||
id: string;
|
||||
status: "PASSED" | "FAILED";
|
||||
reviewStatus: "MARKED" | "MODERATED" | "APPROVED" | "PUBLISHED" | "RETURNED";
|
||||
autoGraded: boolean;
|
||||
totalScore: number;
|
||||
publishedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Mirrors the server's ExamState (ExamStateService / resolveExamState). */
|
||||
export type RegistrationExamState =
|
||||
| "NOT_REGISTERED"
|
||||
| "REGISTERED"
|
||||
| "ATTENDANCE_CONFIRMED"
|
||||
| "NOT_SITTING"
|
||||
| "IN_PROGRESS"
|
||||
| "UNDER_EVALUATION"
|
||||
| "PASSED"
|
||||
| "FAILED";
|
||||
|
||||
export type RegradeOutcome =
|
||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||
|
||||
@@ -189,3 +232,83 @@ export interface ResolveIncidentPayload {
|
||||
outcome: "RESOLVED" | "DISMISSED";
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
/** Bank items appended to a paper without replacing what is already on it. */
|
||||
export interface AddExamQuestionsPayload {
|
||||
examId: string;
|
||||
questionIds: string[];
|
||||
}
|
||||
|
||||
export interface ExamQuestionOptionInput {
|
||||
text: LocalePair;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
/** A question authored straight onto one exam ("add new question from scratch"). */
|
||||
export interface CreateExamQuestionPayload {
|
||||
examId: string;
|
||||
title: LocalePair;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
time?: EstimatedTime;
|
||||
options?: ExamQuestionOptionInput[];
|
||||
}
|
||||
|
||||
export interface ImportedQuestionOption {
|
||||
letter: string;
|
||||
en: string;
|
||||
am: string | null;
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface ImportedQuestionRow {
|
||||
/** 1-based sheet row, as the officer sees it in Excel. */
|
||||
row: number;
|
||||
titleEn: string;
|
||||
titleAm: string | null;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options: ImportedQuestionOption[];
|
||||
}
|
||||
|
||||
export interface QuestionImportError {
|
||||
/** 0 for a file-level problem (missing headers, empty sheet). */
|
||||
row: number;
|
||||
column?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** What one Excel upload came to — the preview, or the errors that stopped it. */
|
||||
export interface QuestionImportReport {
|
||||
rows: ImportedQuestionRow[];
|
||||
errors: QuestionImportError[];
|
||||
imported: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface ImportExamQuestionsPayload {
|
||||
examId: string;
|
||||
file: File;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Summary statistics over one wait interval, in minutes. */
|
||||
export interface WaitStat {
|
||||
count: number;
|
||||
averageMinutes: number | null;
|
||||
minMinutes: number | null;
|
||||
maxMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Candidate waiting/scheduling delays for one session — read-only analytics. */
|
||||
export interface ExamWaitMetrics {
|
||||
examId: string;
|
||||
scheduledStart: string;
|
||||
candidateCount: number;
|
||||
attendedCount: number;
|
||||
startedCount: number;
|
||||
registrationToScheduled: WaitStat;
|
||||
scheduledToAttendance: WaitStat;
|
||||
attendanceToExamStart: WaitStat;
|
||||
scheduledToExamStart: WaitStat;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconFileUpload,
|
||||
IconMessage,
|
||||
IconArrowRight,
|
||||
IconPencilCheck,
|
||||
IconUserCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -19,10 +20,16 @@ import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
type ApplicationDetail,
|
||||
type ApplicationRemark,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
||||
type EntryKind =
|
||||
| 'status'
|
||||
| 'remark'
|
||||
| 'correction'
|
||||
| 'upload'
|
||||
| 'assignment';
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
@@ -37,6 +44,7 @@ interface ActivityEntry {
|
||||
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
status: IconArrowRight,
|
||||
remark: IconMessage,
|
||||
correction: IconPencilCheck,
|
||||
upload: IconFileUpload,
|
||||
assignment: IconUserCheck,
|
||||
};
|
||||
@@ -50,7 +58,18 @@ const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
* the trade-off is that it can only show what the detail payload carries, and
|
||||
* notifications sent to the applicant are not among them.
|
||||
*/
|
||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
export function ActivityRail({
|
||||
detail,
|
||||
remarkLabel,
|
||||
}: {
|
||||
detail: ApplicationDetail;
|
||||
/**
|
||||
* Names a remark's target for the officer. The page owns this because it
|
||||
* holds the licence type config the labels come from; without it the trail
|
||||
* falls back to the raw key, which for a staff remark is a uuid.
|
||||
*/
|
||||
remarkLabel?: (remark: ApplicationRemark) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
@@ -82,18 +101,37 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
}
|
||||
|
||||
for (const remark of detail.remarks ?? []) {
|
||||
const target = remarkLabel?.(remark) ?? remark.targetKey;
|
||||
merged.push({
|
||||
id: `remark-${remark.id}`,
|
||||
kind: 'remark',
|
||||
at: remark.createdAt,
|
||||
actor: t('review.activity.officer', 'Officer'),
|
||||
title: t('review.activity.remarkOn', {
|
||||
target: remark.targetKey,
|
||||
target,
|
||||
defaultValue: 'Correction requested on {{target}}',
|
||||
}),
|
||||
detail: remark.remark,
|
||||
color: remark.resolvedAt ? 'teal' : 'orange',
|
||||
});
|
||||
|
||||
// The correction itself, at the moment the data moved. Distinct from the
|
||||
// remark above (which is the *request*) and from `resolvedAt`, which only
|
||||
// records the applicant ticking the item off — the portal does that in
|
||||
// bulk on resubmit, so it says nothing about what was actually edited.
|
||||
if (remark.valueChangedAt) {
|
||||
merged.push({
|
||||
id: `correction-${remark.id}`,
|
||||
kind: 'correction',
|
||||
at: remark.valueChangedAt,
|
||||
actor: t('review.activity.applicant', 'Applicant'),
|
||||
title: t('review.activity.correctedTarget', {
|
||||
target,
|
||||
defaultValue: 'Corrected {{target}}',
|
||||
}),
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of detail.attachments ?? []) {
|
||||
@@ -118,7 +156,7 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
return merged.sort(
|
||||
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
|
||||
);
|
||||
}, [detail, t]);
|
||||
}, [detail, remarkLabel, t]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
IconEye,
|
||||
IconFileText,
|
||||
IconPencilCheck,
|
||||
IconRotate,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -43,6 +44,11 @@ interface DocumentsTabProps {
|
||||
requirements: DocumentRequirement[];
|
||||
/** Applicant answers used to evaluate conditional document requirements. */
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Document keys the applicant actually re-uploaded during the open
|
||||
* correction round — the files worth re-opening first on a resubmission.
|
||||
*/
|
||||
correctedKeys?: Set<string>;
|
||||
/** documentKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, string>;
|
||||
onToggleFlag: (documentKey: string) => void;
|
||||
@@ -63,6 +69,7 @@ export function DocumentsTab({
|
||||
attachments,
|
||||
requirements,
|
||||
formData,
|
||||
correctedKeys,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
@@ -243,6 +250,16 @@ export function DocumentsTab({
|
||||
{t("review.documents.flagged", "Correction requested")}
|
||||
</Badge>
|
||||
)}
|
||||
{correctedKeys?.has(attachment.documentKey) && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconPencilCheck size={11} />}
|
||||
>
|
||||
{t("review.corrected.badge", "Corrected")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file?.originalName ??
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconMapPin,
|
||||
IconPencilCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
conditionHolds,
|
||||
@@ -34,6 +38,12 @@ interface FormDetailsTabProps {
|
||||
/** The licence type's form schema — the order and labels to render by. */
|
||||
configSections: FormSectionConfig[];
|
||||
currency?: string;
|
||||
/**
|
||||
* Section keys the applicant actually edited during the open correction
|
||||
* round, so a returning application points at itself instead of making the
|
||||
* officer re-read every section to find the two that moved.
|
||||
*/
|
||||
correctedKeys?: Set<string>;
|
||||
/** sectionKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, { remark: string }>;
|
||||
onToggleFlag: (sectionKey: string) => void;
|
||||
@@ -63,6 +73,7 @@ export function FormDetailsTab({
|
||||
formData,
|
||||
configSections,
|
||||
currency,
|
||||
correctedKeys,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
@@ -145,6 +156,16 @@ export function FormDetailsTab({
|
||||
<Text fw={600} size="sm">
|
||||
{section.title}
|
||||
</Text>
|
||||
{correctedKeys?.has(section.key) && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<IconPencilCheck size={10} />}
|
||||
>
|
||||
{t('review.corrected.badge', 'Corrected')}
|
||||
</Badge>
|
||||
)}
|
||||
{missing > 0 && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
|
||||
@@ -9,36 +9,73 @@ const REASONS = {
|
||||
needsFlags: "needs flags",
|
||||
needsCapital: "needs capital",
|
||||
needsInspection: "needs inspection",
|
||||
inspectionFailed: "inspection failed",
|
||||
needsDocumentReviews: "needs document reviews",
|
||||
inspectionNotYetDue: "not yet due",
|
||||
};
|
||||
|
||||
/** Held by someone else, so ownership gating is what the assertions turn on. */
|
||||
function detailFor(licenseTypeKey: string): ApplicationDetail {
|
||||
function detailFor(
|
||||
licenseTypeKey: string,
|
||||
certificateCategory?: string | null,
|
||||
): ApplicationDetail {
|
||||
return {
|
||||
application: {
|
||||
id: "app-1",
|
||||
status: "SUBMITTED",
|
||||
assignedOfficerId: "another-officer",
|
||||
licenseType: { key: licenseTypeKey },
|
||||
licenseType:
|
||||
certificateCategory === undefined
|
||||
? { key: licenseTypeKey }
|
||||
: { key: licenseTypeKey, certificateCategory },
|
||||
},
|
||||
availableEvents: ["claim", "assign", "request-adjustment"],
|
||||
} as unknown as ApplicationDetail;
|
||||
}
|
||||
|
||||
function resolve(licenseTypeKey: string) {
|
||||
function resolve(licenseTypeKey: string, certificateCategory?: string | null) {
|
||||
return resolveActions({
|
||||
detail: detailFor(licenseTypeKey),
|
||||
detail: detailFor(licenseTypeKey, certificateCategory),
|
||||
currentUserId: "me",
|
||||
can: () => true,
|
||||
reasons: REASONS,
|
||||
flaggedCount: 1,
|
||||
hasPendingInspection: false,
|
||||
inspectionNotYetDue: false,
|
||||
latestInspectionPassed: null,
|
||||
allDocumentsAccepted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An inspected licence sitting on INSPECTION_COMPLETED, which is the only
|
||||
* status Final Approve fires from — so the inspection *result* is all that
|
||||
* separates an approvable file from an unapprovable one.
|
||||
*/
|
||||
function inspected(latestInspectionPassed: boolean | null) {
|
||||
const detail = {
|
||||
application: {
|
||||
id: "app-1",
|
||||
status: "INSPECTION_COMPLETED",
|
||||
assignedOfficerId: "me",
|
||||
licenseType: { key: "VESSEL_REGISTRATION", inspectionRequired: true },
|
||||
},
|
||||
availableEvents: ["final-approve", "request-adjustment", "reject"],
|
||||
} as unknown as ApplicationDetail;
|
||||
|
||||
return resolveActions({
|
||||
detail,
|
||||
currentUserId: "me",
|
||||
can: () => true,
|
||||
reasons: REASONS,
|
||||
flaggedCount: 1,
|
||||
hasPendingInspection: false,
|
||||
inspectionNotYetDue: false,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted: true,
|
||||
}).find((a) => a.id === "final-approve");
|
||||
}
|
||||
|
||||
describe("resolveActions — seafarer certificates skip the queue", () => {
|
||||
it("drops claim and assign for a CoC", () => {
|
||||
const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id);
|
||||
@@ -61,4 +98,133 @@ describe("resolveActions — seafarer certificates skip the queue", () => {
|
||||
expect(adjust?.enabled).toBe(false);
|
||||
expect(adjust?.disabledReason).toBe(REASONS.notAssigned);
|
||||
});
|
||||
|
||||
// The Behaviour tab's `certificateCategory` decides, not the key: a type an
|
||||
// administrator configured as a CoC skips the queue whatever it is called,
|
||||
// and a type they cleared the category on is queued like any licence.
|
||||
it("skips the queue for a type configured as a CoC under any key", () => {
|
||||
const ids = resolve("MASTER_MARINER", "COC").map((a) => a.id);
|
||||
expect(ids).not.toContain("claim");
|
||||
expect(ids).not.toContain("assign");
|
||||
});
|
||||
|
||||
it("queues a certificate-looking key whose category was cleared", () => {
|
||||
const actions = resolve("COC_MASTER", null);
|
||||
expect(actions.map((a) => a.id)).toContain("claim");
|
||||
const adjust = actions.find((a) => a.id === "request-adjustment");
|
||||
expect(adjust?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps endorsements in the queue", () => {
|
||||
expect(resolve("ENDORSEMENT_SEAFARER", "ENDORSEMENT").map((a) => a.id)).toContain(
|
||||
"claim",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A completed inspection is not a passed inspection.
|
||||
*
|
||||
* The status alone used to unlock approval, so a vessel that failed its visit
|
||||
* before INSPECTION_FAILED existed — the row stayed on INSPECTION_COMPLETED —
|
||||
* offered a live Approve button the server then refused with
|
||||
* `inspection_not_passed`.
|
||||
*/
|
||||
describe("resolveActions — approval waits on a passed inspection", () => {
|
||||
it("disables final approve when the latest inspection failed", () => {
|
||||
const approve = inspected(false);
|
||||
expect(approve?.enabled).toBe(false);
|
||||
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
|
||||
});
|
||||
|
||||
it("disables final approve when no inspection was conducted", () => {
|
||||
const approve = inspected(null);
|
||||
expect(approve?.enabled).toBe(false);
|
||||
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
|
||||
});
|
||||
|
||||
it("enables final approve once an inspection passed", () => {
|
||||
expect(inspected(true)?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A vessel registration is gated on the visit by its type, not only by the
|
||||
* admin-editable `inspectionRequired` flag — turning the flag off must not
|
||||
* let a vessel be approved, or its certificate issued, on paperwork alone.
|
||||
*/
|
||||
describe("resolveActions — vessel registration needs a passed inspection", () => {
|
||||
function vessel(
|
||||
status: string,
|
||||
events: string[],
|
||||
latestInspectionPassed: boolean | null,
|
||||
licenseTypeKey = "VESSEL_REGISTRATION",
|
||||
) {
|
||||
const detail = {
|
||||
application: {
|
||||
id: "app-1",
|
||||
status,
|
||||
assignedOfficerId: "me",
|
||||
licenseType: { key: licenseTypeKey, inspectionRequired: false },
|
||||
},
|
||||
availableEvents: events,
|
||||
} as unknown as ApplicationDetail;
|
||||
return resolveActions({
|
||||
detail,
|
||||
currentUserId: "me",
|
||||
can: () => true,
|
||||
reasons: REASONS,
|
||||
flaggedCount: 1,
|
||||
hasPendingInspection: false,
|
||||
inspectionNotYetDue: false,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("disables final approve with no passed inspection, flag or not", () => {
|
||||
const approve = vessel("UNDER_EVALUATION", ["final-approve"], null).find(
|
||||
(a) => a.id === "final-approve",
|
||||
);
|
||||
expect(approve?.enabled).toBe(false);
|
||||
expect(approve?.disabledReason).toBe(REASONS.needsInspection);
|
||||
});
|
||||
|
||||
it("disables final approve after a failed inspection", () => {
|
||||
const approve = vessel("UNDER_EVALUATION", ["final-approve"], false).find(
|
||||
(a) => a.id === "final-approve",
|
||||
);
|
||||
expect(approve?.enabled).toBe(false);
|
||||
expect(approve?.disabledReason).toBe(REASONS.inspectionFailed);
|
||||
});
|
||||
|
||||
it("enables final approve once the inspection passed", () => {
|
||||
const approve = vessel("UNDER_EVALUATION", ["final-approve"], true).find(
|
||||
(a) => a.id === "final-approve",
|
||||
);
|
||||
expect(approve?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds the certificate until the inspection passed", () => {
|
||||
const issue = vessel("SCHEDULED", ["issue-certificate"], null).find(
|
||||
(a) => a.id === "issue-certificate",
|
||||
);
|
||||
expect(issue?.enabled).toBe(false);
|
||||
expect(issue?.disabledReason).toBe(REASONS.needsInspection);
|
||||
expect(
|
||||
vessel("SCHEDULED", ["issue-certificate"], true).find(
|
||||
(a) => a.id === "issue-certificate",
|
||||
)?.enabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a non-inspected type alone", () => {
|
||||
const approve = vessel(
|
||||
"UNDER_EVALUATION",
|
||||
["final-approve"],
|
||||
null,
|
||||
"SEAFARER_REGISTRATION",
|
||||
).find((a) => a.id === "final-approve");
|
||||
expect(approve?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
||||
import { isSeafarerCertificate } from './license-types';
|
||||
import {
|
||||
isSeafarerCertificate,
|
||||
requiresPassedInspection,
|
||||
} from './license-types';
|
||||
|
||||
/**
|
||||
* Where an action is rendered. One tier per action, decided here rather than
|
||||
@@ -347,6 +350,7 @@ export interface ResolveContext {
|
||||
needsFlags: string;
|
||||
needsCapital: string;
|
||||
needsInspection: string;
|
||||
inspectionFailed: string;
|
||||
needsDocumentReviews: string;
|
||||
inspectionNotYetDue: string;
|
||||
};
|
||||
@@ -360,6 +364,18 @@ export interface ResolveContext {
|
||||
* button waits (the server refuses early results the same way).
|
||||
*/
|
||||
inspectionNotYetDue: boolean;
|
||||
/**
|
||||
* Outcome of the most recent conducted visit: `true` passed, `false` failed,
|
||||
* `null` none conducted yet.
|
||||
*
|
||||
* A completed inspection is not a passed one. Approval used to turn on the
|
||||
* status alone, so an application sitting at INSPECTION_COMPLETED with a
|
||||
* FAILED result behind it — what a failure produced before INSPECTION_FAILED
|
||||
* existed — offered a live Approve button that the server then refused with
|
||||
* `inspection_not_passed`. This is the same gate the server applies, so the
|
||||
* button is dead here instead of after the click.
|
||||
*/
|
||||
latestInspectionPassed: boolean | null;
|
||||
/**
|
||||
* False while any uploaded document is still unjudged or rejected. Approving
|
||||
* is a statement that every document was checked, so the button stays dead
|
||||
@@ -416,7 +432,11 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
// A seafarer's certificate is reviewed by whoever opens it. There is no
|
||||
// queue to claim it from and no reviewer to assign, so the two actions that
|
||||
// move it through one are dropped and ownership stops gating the decisions.
|
||||
const skipsAssignment = isSeafarerCertificate(app.licenseType?.key);
|
||||
const skipsAssignment = isSeafarerCertificate(app.licenseType);
|
||||
// A vessel registration is decided on the visit, not the file: nothing may
|
||||
// be approved or issued until the latest conducted inspection PASSED, and
|
||||
// that holds even if the type's `inspectionRequired` flag has been edited.
|
||||
const mustPassInspection = requiresPassedInspection(app.licenseType?.key);
|
||||
|
||||
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
||||
(action) => {
|
||||
@@ -513,6 +533,35 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
) {
|
||||
return disabled(reasons.needsInspection);
|
||||
}
|
||||
// Mirrors the server's `inspection_not_passed`: the status says a
|
||||
// visit happened, the result says whether it may be approved.
|
||||
if (
|
||||
(app.licenseType?.inspectionRequired || mustPassInspection) &&
|
||||
ctx.latestInspectionPassed !== true
|
||||
) {
|
||||
return disabled(
|
||||
ctx.latestInspectionPassed === false
|
||||
? reasons.inspectionFailed
|
||||
: reasons.needsInspection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The certificate is the other thing a passed visit unlocks. Approval
|
||||
// is already gated above, so this only bites on a file that reached
|
||||
// issuance some other way — but a vessel certificate with no passed
|
||||
// inspection behind it is exactly the document this rule exists to
|
||||
// prevent, so it is checked at the point of issue too.
|
||||
if (
|
||||
(action.id === 'schedule-issuance' || action.id === 'issue-certificate') &&
|
||||
mustPassInspection &&
|
||||
ctx.latestInspectionPassed !== true
|
||||
) {
|
||||
return disabled(
|
||||
ctx.latestInspectionPassed === false
|
||||
? reasons.inspectionFailed
|
||||
: reasons.needsInspection,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...action, enabled: true };
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
IconUsers,
|
||||
type Icon,
|
||||
} from '@tabler/icons-react';
|
||||
import type { LicenseApplication, LicenseType } from '@ema-platform/api';
|
||||
import type {
|
||||
CertificateCategory,
|
||||
LicenseApplication,
|
||||
LicenseType,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Presentation-only metadata per licence type.
|
||||
@@ -152,12 +156,33 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
|
||||
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
|
||||
|
||||
/** The configured categories that mark a type as a seafarer's own certificate. */
|
||||
const SEAFARER_CERTIFICATE_CATEGORIES: ReadonlyArray<CertificateCategory> = [
|
||||
'COC',
|
||||
'COP',
|
||||
'GOC',
|
||||
];
|
||||
|
||||
/**
|
||||
* CoC, CoP and the endorsement/GOC family: a seafarer's own certificate rather
|
||||
* than an organisation's licence. They skip the claim/assign queue — see
|
||||
* CoC, CoP and the GOC family: a seafarer's own certificate rather than an
|
||||
* organisation's licence. They skip the claim/assign queue — see
|
||||
* `resolveActions`.
|
||||
*
|
||||
* Decided by the `certificateCategory` an administrator sets on the
|
||||
* certificate-requirements Behaviour tab, so a type configured as a CoC in the
|
||||
* backoffice is treated as one whatever its key. The key prefixes are only a
|
||||
* fallback for a payload that carries no category field at all (an older
|
||||
* server, or a bare key).
|
||||
*/
|
||||
export function isSeafarerCertificate(key: string | undefined): boolean {
|
||||
export function isSeafarerCertificate(
|
||||
licenseType: Pick<LicenseType, 'key' | 'certificateCategory'> | string | undefined,
|
||||
): boolean {
|
||||
if (!licenseType) return false;
|
||||
if (typeof licenseType !== 'string' && licenseType.certificateCategory !== undefined) {
|
||||
const category = licenseType.certificateCategory;
|
||||
return category !== null && SEAFARER_CERTIFICATE_CATEGORIES.includes(category);
|
||||
}
|
||||
const key = typeof licenseType === 'string' ? licenseType : licenseType.key;
|
||||
if (!key) return false;
|
||||
return (
|
||||
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
|
||||
@@ -166,6 +191,21 @@ export function isSeafarerCertificate(key: string | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types that may never be approved, nor have a certificate issued, without a
|
||||
* conducted inspection that PASSED (US-VES-007 for vessel registration).
|
||||
*
|
||||
* Keyed on the type rather than `inspectionRequired` alone: that flag is an
|
||||
* admin-editable column, and flipping it off must not quietly let a vessel be
|
||||
* registered on paperwork alone. `resolveActions` applies this on top of the
|
||||
* flag, never instead of it.
|
||||
*/
|
||||
const PASSED_INSPECTION_REQUIRED_KEYS = new Set(['VESSEL_REGISTRATION']);
|
||||
|
||||
export function requiresPassedInspection(key: string | undefined): boolean {
|
||||
return Boolean(key && PASSED_INSPECTION_REQUIRED_KEYS.has(key));
|
||||
}
|
||||
|
||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||
export function presentationFor(key: string | undefined): LicenseTypePresentation {
|
||||
if (key && PRESENTATION[key]) return PRESENTATION[key];
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type ApplicationKind,
|
||||
type ExamState,
|
||||
type LicenseApplication,
|
||||
type QueueFilter,
|
||||
} from "@ema-platform/api";
|
||||
@@ -16,6 +17,18 @@ const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||
RENEWAL: "teal",
|
||||
REISSUE: "orange",
|
||||
};
|
||||
|
||||
/** Same palette as the COC detail's Examination panel. */
|
||||
const EXAM_STATE_COLORS: Record<ExamState, string> = {
|
||||
NOT_REGISTERED: "gray",
|
||||
REGISTERED: "cyan",
|
||||
ATTENDANCE_CONFIRMED: "indigo",
|
||||
NOT_SITTING: "orange",
|
||||
IN_PROGRESS: "blue",
|
||||
UNDER_EVALUATION: "yellow",
|
||||
PASSED: "teal",
|
||||
FAILED: "red",
|
||||
};
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { dateDisplayer } from "@ema-platform/shared";
|
||||
import { computeSla } from "../../sla";
|
||||
@@ -142,6 +155,23 @@ export function licenseQueueColumns(
|
||||
`queue.statusValues.${row.original.status}`,
|
||||
STATUS_LABELS[row.original.status],
|
||||
);
|
||||
// The exam leg is shown as the sitting actually stands — registered,
|
||||
// present, sat, passed — derived server-side from the registration,
|
||||
// the attempt and the published mark, the same reading the COC detail
|
||||
// and the applicant's portal use. The application status alone
|
||||
// lagged behind a published result, which is how the queue kept
|
||||
// saying "exam scheduled" over a pass.
|
||||
const examState = row.original.examState;
|
||||
if (examState) {
|
||||
const examLabel = t(`review.exam.state.${examState}`, examState);
|
||||
return (
|
||||
<Tooltip label={`${examLabel} · ${label}`} withArrow>
|
||||
<Badge color={EXAM_STATE_COLORS[examState]} variant="light">
|
||||
{examLabel}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
|
||||
@@ -133,6 +133,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
||||
return type.inspectionRequired;
|
||||
}
|
||||
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||
if (status === "SCHEDULED") return Boolean(type.requiresIssuanceScheduling);
|
||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||
return true;
|
||||
});
|
||||
@@ -402,13 +403,11 @@ export function LicenseQueuePage() {
|
||||
// "c" now opens the assign dialog on an undispatched row. Kept on the
|
||||
// same key: it is still "do the queue's primary action to this row",
|
||||
// and rebinding a shortcut officers have in their fingers costs more
|
||||
// than the name mismatch.
|
||||
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
|
||||
setAssignTarget(cursorRow);
|
||||
// Only unclaimed rows on a claimable queue can be claimed; pressing c
|
||||
// elsewhere is a no-op rather than an error the officer has to read.
|
||||
// than the name mismatch. Only undispatched rows on a queue with an
|
||||
// unclaimed pool qualify; pressing c elsewhere is a no-op rather than
|
||||
// an error the officer has to read.
|
||||
if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
|
||||
handleClaim(cursorRow.id);
|
||||
setAssignTarget(cursorRow);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
@@ -473,12 +472,10 @@ export function LicenseQueuePage() {
|
||||
assigning,
|
||||
onAssign: setAssignTarget,
|
||||
onOpen: (id) => navigate(`/licence-review/${id}`),
|
||||
// Non-logistics applications aren't dispatched off a shared queue (see
|
||||
// `savedViewsForFamily`) — every row opens straight to Review.
|
||||
assignable: isLogistics !== false,
|
||||
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
|
||||
// never claimed — every row opens straight to Review.
|
||||
claimable,
|
||||
// never dispatched off a shared queue — every row opens straight to
|
||||
// Review.
|
||||
assignable: claimable,
|
||||
}),
|
||||
],
|
||||
[
|
||||
@@ -772,19 +769,6 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
{claimable && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
useRescheduleInspectionMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
uploadDocument,
|
||||
type ApplicationRemark,
|
||||
type RemarkTargetType,
|
||||
type StaffEvidenceRequirement,
|
||||
} from "@ema-platform/api";
|
||||
@@ -92,6 +93,7 @@ import {
|
||||
type DecisionSubmission,
|
||||
} from "../../components/DecisionConfirmModal";
|
||||
import { ActivityRail } from "../../components/ActivityRail";
|
||||
import { CorrectedItemsPanel } from "../../components/CorrectedItemsPanel";
|
||||
import { DocumentsTab } from "../../components/DocumentsTab";
|
||||
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
||||
import { ApplicantCard } from "../../components/ApplicantCard";
|
||||
@@ -102,6 +104,7 @@ import { reviewStaffColumns } from "./columns";
|
||||
import {
|
||||
evaluateEligibility,
|
||||
presentationFor,
|
||||
requiresPassedInspection,
|
||||
} from "../../config/license-types";
|
||||
import {
|
||||
resolveActions,
|
||||
@@ -331,6 +334,40 @@ export function LicenseReviewPage() {
|
||||
) < pendingInspection.scheduledDate.slice(0, 10),
|
||||
);
|
||||
|
||||
// The most recent visit that actually happened. `findForApplication` orders
|
||||
// newest first, so the first COMPLETED row is the one that decides approval —
|
||||
// a re-inspection that passed supersedes the failure before it.
|
||||
const latestConductedInspection = inspections.find(
|
||||
(i) => i.status === 'COMPLETED',
|
||||
);
|
||||
const latestInspectionPassed = latestConductedInspection
|
||||
? latestConductedInspection.result === 'PASSED'
|
||||
: null;
|
||||
|
||||
// A vessel registration's visit is pass/fail per area, nothing in between:
|
||||
// there is no "fix and come back" state on a vessel, so the result form
|
||||
// offers only those two, and the visit can only be recorded as PASSED when
|
||||
// every area passed (US-VES-007).
|
||||
const strictInspection = requiresPassedInspection(
|
||||
loadedApp?.licenseType?.key,
|
||||
);
|
||||
const checklistOptions = [
|
||||
{ value: "PASS", label: t("review.checkPass", "Pass") },
|
||||
...(strictInspection
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: "NEEDS_CORRECTION",
|
||||
label: t("review.checkFix", "Fix"),
|
||||
},
|
||||
]),
|
||||
{ value: "FAIL", label: t("review.checkFail", "Fail") },
|
||||
];
|
||||
const allChecklistPassed = INSPECTION_CHECKLIST_ITEMS.every(
|
||||
(item) => (checklist[item.key] ?? "PASS") === "PASS",
|
||||
);
|
||||
const passBlocked = strictInspection && !allChecklistPassed;
|
||||
|
||||
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' },
|
||||
@@ -384,6 +421,100 @@ export function LicenseReviewPage() {
|
||||
[flags, data?.staff, t, localized, roleNameByKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* What the applicant actually changed when they sent the file back.
|
||||
*
|
||||
* A resubmission used to arrive as a bare "round N" badge, which told the
|
||||
* officer that something had been corrected but not what — so re-review meant
|
||||
* re-reading the whole application. `valueChangedAt` is stamped by the edit
|
||||
* itself, so these are the items whose data really moved; the ones the
|
||||
* applicant ticked off without touching are listed separately, because they
|
||||
* are the likeliest reason the file comes back a third time.
|
||||
*
|
||||
* Scoped to the current round: `requestAdjustment` increments the counter and
|
||||
* `resubmit` does not, so the open round is the one just answered.
|
||||
*/
|
||||
/**
|
||||
* A remark target as the officer reads it: section title, document name, or
|
||||
* the staff member's role and name. The wire carries only the key — and for
|
||||
* a staff remark that key is an ApplicationStaff uuid — so every place that
|
||||
* shows a remark (the corrections panel, the activity rail) goes through this.
|
||||
*/
|
||||
const remarkLabel = useMemo(() => {
|
||||
const sectionTitles = new Map(
|
||||
(requirements?.licenseType.formSchema.sections ?? []).map((section) => [
|
||||
section.key,
|
||||
localized(section.title),
|
||||
]),
|
||||
);
|
||||
const documentNames = new Map(
|
||||
(requirements?.documentRequirements ?? []).map((requirement) => [
|
||||
requirement.key,
|
||||
localized(requirement.name),
|
||||
]),
|
||||
);
|
||||
|
||||
return (remark: ApplicationRemark): string => {
|
||||
if (remark.targetType === "FORM_SECTION") {
|
||||
return sectionTitles.get(remark.targetKey) || humaniseKey(remark.targetKey);
|
||||
}
|
||||
if (remark.targetType === "DOCUMENT") {
|
||||
return documentNames.get(remark.targetKey) || humaniseKey(remark.targetKey);
|
||||
}
|
||||
const member = data?.staff.find((s) => s.id === remark.targetKey);
|
||||
return member
|
||||
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
||||
: t("review.staffMember", "Staff member");
|
||||
};
|
||||
}, [data?.staff, requirements, localized, roleNameByKey, t]);
|
||||
|
||||
const correction = useMemo(() => {
|
||||
const round = data?.application.adjustmentRound ?? 0;
|
||||
if (!data || round === 0) {
|
||||
return {
|
||||
round,
|
||||
corrected: [],
|
||||
untouched: [],
|
||||
sectionKeys: new Set<string>(),
|
||||
documentKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
const roundRemarks = (data.remarks ?? []).filter(
|
||||
(r) => r.roundNumber === round,
|
||||
);
|
||||
const item = (remark: ApplicationRemark) => ({
|
||||
key: remark.id,
|
||||
label: remarkLabel(remark),
|
||||
remark: remark.remark,
|
||||
changedAt: remark.valueChangedAt ?? remark.resolvedAt ?? remark.createdAt,
|
||||
});
|
||||
|
||||
const changed = roundRemarks.filter((r) => r.valueChangedAt);
|
||||
return {
|
||||
round,
|
||||
corrected: changed.map(item),
|
||||
// Only once the file is actually back. While it is still with the
|
||||
// applicant the round is being worked on, and calling a not-yet-edited
|
||||
// item "marked done but left unchanged" would be an accusation about
|
||||
// work still in progress.
|
||||
untouched:
|
||||
data.application.status === "RESUBMIT_REQUIRED"
|
||||
? []
|
||||
: roundRemarks
|
||||
.filter((r) => !r.valueChangedAt && r.isResolved)
|
||||
.map(item),
|
||||
// Keyed per target type: a section and a document may legitimately
|
||||
// share a key, and a badge on the wrong one would misdirect the officer.
|
||||
sectionKeys: new Set(
|
||||
changed.filter((r) => r.targetType === "FORM_SECTION").map((r) => r.targetKey),
|
||||
),
|
||||
documentKeys: new Set(
|
||||
changed.filter((r) => r.targetType === "DOCUMENT").map((r) => r.targetKey),
|
||||
),
|
||||
};
|
||||
}, [data, remarkLabel]);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return resolveActions({
|
||||
@@ -393,6 +524,7 @@ export function LicenseReviewPage() {
|
||||
flaggedCount: flagged.length,
|
||||
hasPendingInspection: Boolean(pendingInspection),
|
||||
inspectionNotYetDue,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted,
|
||||
reasons: {
|
||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||
@@ -401,6 +533,10 @@ export function LicenseReviewPage() {
|
||||
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
||||
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
||||
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
||||
inspectionFailed: t(
|
||||
'review.disabled.inspectionFailed',
|
||||
'The inspection failed — a re-inspection must pass before approval',
|
||||
),
|
||||
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
|
||||
date: pendingInspection?.scheduledDate
|
||||
? showDate(pendingInspection.scheduledDate)
|
||||
@@ -422,7 +558,19 @@ export function LicenseReviewPage() {
|
||||
}),
|
||||
},
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]);
|
||||
}, [
|
||||
data,
|
||||
currentUserId,
|
||||
can,
|
||||
flagged.length,
|
||||
pendingInspection,
|
||||
inspectionNotYetDue,
|
||||
latestInspectionPassed,
|
||||
allDocumentsAccepted,
|
||||
documentProgress,
|
||||
showDate,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Location answers are tree ids. The picker the applicant used resolves them
|
||||
// client-side from the same list, so the reviewer reads the place rather than
|
||||
@@ -902,9 +1050,8 @@ export function LicenseReviewPage() {
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/licence-review/${related.id}`)}
|
||||
>
|
||||
{related.licenseType?.key === "SEAMAN_BOOK"
|
||||
? "Seaman Book"
|
||||
: "BTC"}{" "}
|
||||
{localized(related.licenseType?.name) ||
|
||||
related.licenseType?.key}{" "}
|
||||
· {related.applicationNumber} ·{" "}
|
||||
{STATUS_LABELS[related.status]}
|
||||
</Badge>
|
||||
@@ -935,6 +1082,12 @@ export function LicenseReviewPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<CorrectedItemsPanel
|
||||
round={correction.round}
|
||||
items={correction.corrected}
|
||||
untouched={correction.untouched}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
{/* Zone 1 — sticky summary rail. */}
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
@@ -1130,6 +1283,7 @@ export function LicenseReviewPage() {
|
||||
formData={app.formData ?? {}}
|
||||
configSections={configSections}
|
||||
currency={app.feeCurrency ?? undefined}
|
||||
correctedKeys={correction.sectionKeys}
|
||||
flags={flags}
|
||||
onToggleFlag={(sectionKey) =>
|
||||
toggleFlag("FORM_SECTION", sectionKey)
|
||||
@@ -1205,6 +1359,7 @@ export function LicenseReviewPage() {
|
||||
attachments={data.attachments}
|
||||
requirements={requirements?.documentRequirements ?? []}
|
||||
formData={app.formData ?? {}}
|
||||
correctedKeys={correction.documentKeys}
|
||||
flags={documentFlags}
|
||||
onToggleFlag={(key) => toggleFlag("DOCUMENT", key)}
|
||||
onFlagRemark={(key, remark) =>
|
||||
@@ -1330,7 +1485,8 @@ export function LicenseReviewPage() {
|
||||
{/* Page-level, not inside the inspection tab: a license type
|
||||
configured without an inspection detail section must still show
|
||||
why approval is blocked if it ever lands here. */}
|
||||
{status === "INSPECTION_FAILED" && (
|
||||
{(status === "INSPECTION_FAILED" ||
|
||||
latestInspectionPassed === false) && (
|
||||
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{t(
|
||||
"review.inspectionFailedBlocked",
|
||||
@@ -1358,7 +1514,7 @@ export function LicenseReviewPage() {
|
||||
{/* Zone 3 — activity and audit trail. */}
|
||||
{railOpen && (
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<ActivityRail detail={data} />
|
||||
<ActivityRail detail={data} remarkLabel={remarkLabel} />
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
@@ -1563,17 +1719,18 @@ export function LicenseReviewPage() {
|
||||
[item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION",
|
||||
}))
|
||||
}
|
||||
data={[
|
||||
{ value: "PASS", label: t("review.checkPass", "Pass") },
|
||||
{
|
||||
value: "NEEDS_CORRECTION",
|
||||
label: t("review.checkFix", "Fix"),
|
||||
},
|
||||
{ value: "FAIL", label: t("review.checkFail", "Fail") },
|
||||
]}
|
||||
data={checklistOptions}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{passBlocked && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"review.checklistMustPass",
|
||||
"Every item must pass before the inspection can be recorded as passed.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Textarea
|
||||
label={t("review.findings", "Findings")}
|
||||
@@ -1653,7 +1810,12 @@ export function LicenseReviewPage() {
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="lg"
|
||||
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
|
||||
disabled={
|
||||
!findings.trim() ||
|
||||
!pendingInspection ||
|
||||
inspectionNotYetDue ||
|
||||
passBlocked
|
||||
}
|
||||
aria-label={t("review.passed", "Passed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -1848,4 +2010,16 @@ function SummaryRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A camelCase or snake_case key as a heading.
|
||||
*
|
||||
* Only reached when the licence type's config has no label for the key — a
|
||||
* section the schema has since dropped, say. Better than printing
|
||||
* `vesselParticulars` at an officer.
|
||||
*/
|
||||
function humaniseKey(key: string): string {
|
||||
const spaced = key.replace(/([A-Z])/g, " $1").replace(/[_-]+/g, " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
|
||||
}
|
||||
|
||||
export default LicenseReviewPage;
|
||||
|
||||
@@ -61,6 +61,16 @@ const resultApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/**
|
||||
* Applicant-level publication: only this candidate's mark, notification
|
||||
* and application are affected. The exam and every other candidate stay
|
||||
* exactly as they were.
|
||||
*/
|
||||
publishResult: builder.mutation<Result, string>({
|
||||
query: (id) => ({ url: `/results/${id}/publish`, method: 'POST' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Every approved result for a session at once — a convenience over publishResult. */
|
||||
publishExamResults: builder.mutation<
|
||||
{ examId: string; published: number; skipped: number },
|
||||
string
|
||||
@@ -98,6 +108,7 @@ export const {
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
useGetPendingAppealsQuery,
|
||||
useDecideAppealMutation,
|
||||
|
||||
@@ -7,9 +7,9 @@ export function recordResultColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: {
|
||||
scores: Record<string, number>;
|
||||
scores: Record<string, number | ''>;
|
||||
questionRemarks: Record<string, string>;
|
||||
onScoreChange: (questionId: string, value: number) => void;
|
||||
onScoreChange: (questionId: string, value: number | '') => void;
|
||||
onRemarkChange: (questionId: string, value: string) => void;
|
||||
/** The candidate's own answer + auto-score, when available (empty for
|
||||
* an OFFLINE candidate or one who hasn't sat an online attempt). */
|
||||
@@ -54,8 +54,17 @@ export function recordResultColumns(
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<NumberInput
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
// Empty until the examiner types a mark: an untouched box is not
|
||||
// a zero, and the form will not save while one is left empty.
|
||||
value={handlers.scores[row.original.id] ?? ''}
|
||||
onChange={(v) =>
|
||||
handlers.onScoreChange(
|
||||
row.original.id,
|
||||
v === '' || v === null || v === undefined ? '' : Number(v),
|
||||
)
|
||||
}
|
||||
placeholder={t('result.recordModal.scorePlaceholder')}
|
||||
error={handlers.scores[row.original.id] === '' || handlers.scores[row.original.id] === undefined}
|
||||
min={0}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
|
||||
@@ -45,7 +45,10 @@ export function RecordResultModal({
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const [seafarerSearch, setSeafarerSearch] = useState('');
|
||||
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState<Record<string, number>>({});
|
||||
// A score is either a number the examiner typed or not yet entered. It is
|
||||
// never defaulted to 0: an untouched box must not become a mark of zero,
|
||||
// and a paper with a box left empty must not be saved at all.
|
||||
const [scores, setScores] = useState<Record<string, number | ''>>({});
|
||||
const [questionRemarks, setQuestionRemarks] = useState<Record<string, string>>({});
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
@@ -78,7 +81,7 @@ export function RecordResultModal({
|
||||
// only ever fills in blanks, never stomps a manual edit already made.
|
||||
useEffect(() => {
|
||||
if (!gradingSheet) return;
|
||||
const autoScores: Record<string, number> = {};
|
||||
const autoScores: Record<string, number | ''> = {};
|
||||
for (const q of gradingSheet.questions) {
|
||||
if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
|
||||
}
|
||||
@@ -88,10 +91,17 @@ export function RecordResultModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gradingSheet]);
|
||||
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
)
|
||||
// Only candidates who sat the paper *and have no mark yet*. A candidate the
|
||||
// exam engine has already graded — or an examiner has already marked — is
|
||||
// not offered: the API refuses a second result (result_already_recorded),
|
||||
// and an engine-produced mark is locked in any case. Nobody is asked to
|
||||
// hand-record a result the system already holds.
|
||||
const sat = (registrations ?? []).filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
);
|
||||
const alreadyMarked = sat.filter((registration) => registration.result).length;
|
||||
const seafarerOptions = sat
|
||||
.filter((registration) => !registration.result)
|
||||
.map((registration) => ({
|
||||
value: registration.profileId,
|
||||
label: `${registration.admissionNumber} — ${[
|
||||
@@ -107,7 +117,8 @@ export function RecordResultModal({
|
||||
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
|
||||
: seafarerOptions;
|
||||
|
||||
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
|
||||
const unscored = questions.filter((q) => scores[q.id] === '' || scores[q.id] === undefined);
|
||||
const totalScore = questions.reduce((sum, q) => sum + (Number(scores[q.id]) || 0), 0);
|
||||
const maxScore = questions.reduce((sum, q) => sum + (q.points ?? 0), 0);
|
||||
// The cutting point is read per the exam's configured evaluation method —
|
||||
// an AVERAGE or PERCENTAGE exam must not be graded as a raw sum.
|
||||
@@ -123,7 +134,7 @@ export function RecordResultModal({
|
||||
: totalScore;
|
||||
const passed = effectiveScore >= exam.cuttingPoint;
|
||||
|
||||
const handleScoreChange = (questionId: string, value: number) => {
|
||||
const handleScoreChange = (questionId: string, value: number | '') => {
|
||||
setScores((prev) => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
@@ -136,21 +147,34 @@ export function RecordResultModal({
|
||||
notify.error(t('result.recordModal.seafarerRequired'));
|
||||
return;
|
||||
}
|
||||
// The same rules the API enforces (result_score_required,
|
||||
// result_remark_required), caught here so the officer is told which box
|
||||
// is empty before the request goes out. An empty mark is refused, never
|
||||
// scored as zero — and never turned into a pass.
|
||||
if (unscored.length) {
|
||||
notify.error(t('result.recordModal.scoresRequired', { count: unscored.length }));
|
||||
return;
|
||||
}
|
||||
if (!remark.trim()) {
|
||||
notify.error(t('result.recordModal.reasonRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const breakdowns = questions.map((q) => ({
|
||||
questionId: q.id,
|
||||
score: scores[q.id] ?? 0,
|
||||
score: Number(scores[q.id]),
|
||||
remark: questionRemarks[q.id] ?? '',
|
||||
}));
|
||||
// The outcome is not sent: the API derives PASSED/FAILED from the
|
||||
// session's evaluation method and cutting point, and stamps the
|
||||
// examiner on the row (US-EXAM-011). The preview below shows what that
|
||||
// computation will produce.
|
||||
// The outcome is not typed in: the API derives PASSED/FAILED from the
|
||||
// session's evaluation method and cutting point over the scores just
|
||||
// entered, and stamps the examiner on the row (US-EXAM-011). The
|
||||
// preview below shows exactly what that computation will produce, so
|
||||
// the officer is confirming an explicit outcome, not guessing one.
|
||||
await createResult({
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
remark: { en: remark.trim(), am: '' },
|
||||
}).unwrap();
|
||||
notify.success(t('result.recordModal.saveSuccess'));
|
||||
setSelectedSeafarerId(null);
|
||||
@@ -161,14 +185,21 @@ export function RecordResultModal({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('result.recordModal.saveError'));
|
||||
const [code] = key.split(':');
|
||||
notify.error(
|
||||
key === 'candidate_not_registered'
|
||||
? 'This candidate is not registered for the session.'
|
||||
: key.startsWith('candidate_not_present')
|
||||
? `No paper to mark — the register says ${key.split(':')[1] ?? ''}.`
|
||||
: key === 'result_already_recorded'
|
||||
? 'A result has already been recorded for this candidate.'
|
||||
: key,
|
||||
code === 'candidate_not_registered'
|
||||
? t('result.recordModal.errors.notRegistered')
|
||||
: code === 'candidate_not_present'
|
||||
? t('result.recordModal.errors.notPresent', { ruling: key.split(':')[1] ?? '' })
|
||||
: code === 'result_already_recorded'
|
||||
? t('result.recordModal.errors.alreadyRecorded')
|
||||
: code === 'result_score_required' ||
|
||||
code === 'result_incomplete_breakdowns' ||
|
||||
code === 'result_breakdowns_required'
|
||||
? t('result.recordModal.scoresRequired', { count: unscored.length || 1 })
|
||||
: code === 'result_remark_required'
|
||||
? t('result.recordModal.reasonRequired')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -191,6 +222,16 @@ export function RecordResultModal({
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
{alreadyMarked > 0 && (
|
||||
<Alert color="grape" icon={<IconInfoCircle size={15} />}>
|
||||
{t('result.recordModal.alreadyMarkedHint', { count: alreadyMarked })}
|
||||
</Alert>
|
||||
)}
|
||||
{sat.length > 0 && seafarerOptions.length === 0 && (
|
||||
<Alert color="teal" icon={<IconCheck size={15} />}>
|
||||
{t('result.recordModal.allMarked')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedSeafarerId && questions.length > 0 && (
|
||||
<>
|
||||
@@ -226,18 +267,30 @@ export function RecordResultModal({
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{unscored.length > 0 && (
|
||||
<Text fz="xs" c="orange">
|
||||
{t('result.recordModal.scoresRequired', { count: unscored.length })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('result.recordModal.remarkOptional')}
|
||||
placeholder={t('result.recordModal.remarkPlaceholder')}
|
||||
label={t('result.recordModal.reason')}
|
||||
placeholder={t('result.recordModal.reasonPlaceholder')}
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]} hideOnly>
|
||||
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={unscored.length > 0 || !remark.trim()}
|
||||
>
|
||||
{t('result.saveResult')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { IconDotsVertical, IconEye, IconLock, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
@@ -21,6 +21,10 @@ export function resultActionsColumn(
|
||||
label: t('result.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
// An engine-produced mark is immutable outside the appeal workflow, so
|
||||
// the moderation/return/delete entries are not offered for it — the API
|
||||
// refuses them anyway (result_locked_auto_graded).
|
||||
const locked = r.autoGraded && !r.appealUnlockId;
|
||||
return (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
@@ -29,16 +33,21 @@ export function resultActionsColumn(
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
<Menu.Item
|
||||
leftSection={locked ? <IconLock size={14} /> : <IconEye size={14} />}
|
||||
onClick={() => handlers.onViewDetail(r)}
|
||||
>
|
||||
{locked ? t('result.action.view') : t('result.action.viewEdit')}
|
||||
</Menu.Item>
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
{!locked && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="yellow" onClick={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Item color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
@@ -46,7 +55,7 @@ export function resultActionsColumn(
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
{!locked && (r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
LICENSE_PERMISSIONS.MODERATE_EXAM_RESULT,
|
||||
@@ -66,20 +75,22 @@ export function resultActionsColumn(
|
||||
leftSection={<IconSend size={14} />}
|
||||
onClick={() => handlers.onPublish(r)}
|
||||
>
|
||||
{t('result.review.publish')}
|
||||
{t('result.review.publishOne')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{!locked && r.reviewStatus !== 'PUBLISHED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_EXAM_RESULT]} hideOnly>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Menu.Item>
|
||||
</RequirePermission>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useCallback, type ElementType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
|
||||
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput, Alert} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
|
||||
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend, IconLock} from '@tabler/icons-react';
|
||||
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
} from '../../api/result-api';
|
||||
import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
||||
@@ -89,6 +90,7 @@ export function ResultPage() {
|
||||
const [approveResult, { isLoading: isApproving }] = useApproveResultMutation();
|
||||
const [returnResult, { isLoading: isReturning }] = useReturnResultMutation();
|
||||
const [publishResults, { isLoading: isPublishing }] = usePublishExamResultsMutation();
|
||||
const [publishOne, { isLoading: isPublishingOne }] = usePublishResultMutation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [examFilter, setExamFilter] = useState<string | null>(null);
|
||||
@@ -175,7 +177,9 @@ export function ResultPage() {
|
||||
notify.error(
|
||||
key === 'result_locked_after_approval'
|
||||
? t('result.review.lockedAfterApproval')
|
||||
: key,
|
||||
: key === 'result_locked_auto_graded'
|
||||
? t('result.review.autoGradedLocked')
|
||||
: key,
|
||||
);
|
||||
} finally {
|
||||
setDetailSaving(false);
|
||||
@@ -233,19 +237,33 @@ export function ResultPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */
|
||||
/**
|
||||
* Applicant-level publication: this row's candidate only. The exam and every
|
||||
* other candidate's mark are untouched — publishing A must never make B's
|
||||
* result visible or flip the session everyone shares.
|
||||
*/
|
||||
const handleConfirmPublish = async () => {
|
||||
if (!publishTarget) return;
|
||||
try {
|
||||
const outcome = await publishResults(publishTarget.examId).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
await publishOne(publishTarget.id).unwrap();
|
||||
notify.success(t('result.review.publishedOne'));
|
||||
closePublish();
|
||||
setPublishTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
const key = extractErrorMessage(error, t('result.review.error'));
|
||||
notify.error(
|
||||
key === 'result_not_approved'
|
||||
? t('result.review.publishNeedsApproval')
|
||||
: key === 'result_already_published'
|
||||
? t('result.review.alreadyPublished')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// The engine's mark is read-only here: only an upheld appeal reopens it.
|
||||
const detailLocked = Boolean(detailResult?.autoGraded && !detailResult?.appealUnlockId);
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
@@ -413,6 +431,11 @@ export function ResultPage() {
|
||||
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${detailResult.reviewStatus}`)}
|
||||
</Badge>
|
||||
{detailResult.autoGraded && (
|
||||
<Badge variant="light" color="grape" leftSection={<IconLock size={10} />}>
|
||||
{t('result.review.autoGraded')}
|
||||
</Badge>
|
||||
)}
|
||||
{detailResult.preModerationScore != null && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('result.review.originalScore')}: {detailResult.preModerationScore}
|
||||
@@ -422,6 +445,12 @@ export function ResultPage() {
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{detailLocked && (
|
||||
<Alert color="grape" icon={<IconLock size={16} />}>
|
||||
{t('result.review.autoGradedLocked')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<BilingualInput
|
||||
label={t('result.detail.remark')}
|
||||
placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }}
|
||||
@@ -462,6 +491,8 @@ export function ResultPage() {
|
||||
type="number"
|
||||
style={{ width: 80 }}
|
||||
value={b.score}
|
||||
readOnly={detailLocked}
|
||||
disabled={detailLocked}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
||||
@@ -474,6 +505,8 @@ export function ResultPage() {
|
||||
size="xs"
|
||||
placeholder="Optional"
|
||||
value={b.remark ?? ''}
|
||||
readOnly={detailLocked}
|
||||
disabled={detailLocked}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
||||
@@ -501,6 +534,7 @@ export function ResultPage() {
|
||||
onClick={handleDetailSave}
|
||||
size="sm"
|
||||
loading={detailSaving}
|
||||
disabled={detailLocked}
|
||||
leftSection={<IconDeviceFloppy size={15} />}
|
||||
>
|
||||
{t('result.save')}
|
||||
@@ -568,16 +602,19 @@ export function ResultPage() {
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publish')} size="sm">
|
||||
<Modal opened={publishOpened} onClose={closePublish} title={t('result.review.publishOne')} size="sm">
|
||||
<Text mb="md">
|
||||
{t('result.review.publishConfirmText', {
|
||||
{t('result.review.publishOneConfirmText', {
|
||||
candidate: publishTarget?.seafarer
|
||||
? `${publishTarget.seafarer.firstName} ${publishTarget.seafarer.lastName}`
|
||||
: publishTarget?.seafarerId.slice(0, 8) ?? '',
|
||||
exam: publishTarget ? getExamTitle(publishTarget.examId) : '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closePublish} size="sm">{t('result.cancel')}</Button>
|
||||
<Button color="teal" loading={isPublishing} onClick={handleConfirmPublish} size="sm">
|
||||
{t('result.review.publish')}
|
||||
<Button color="teal" loading={isPublishingOne} onClick={handleConfirmPublish} size="sm">
|
||||
{t('result.review.publishOne')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -62,6 +62,13 @@ export interface Result {
|
||||
remark: { en: string; am: string } | null;
|
||||
status: ExamResultStatus;
|
||||
reviewStatus: ResultReviewStatus;
|
||||
/**
|
||||
* The exam engine produced this mark from the candidate's own answers and
|
||||
* the configured answer key. Locked against every ordinary edit — only an
|
||||
* upheld appeal reopens it — so the score inputs are read-only for it.
|
||||
*/
|
||||
autoGraded: boolean;
|
||||
appealUnlockId?: string | null;
|
||||
markedById: string | null;
|
||||
markedAt: string | null;
|
||||
preModerationScore: number | null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useConfirmSeafarerDocumentPaymentMutation,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetSeafarerDocumentReviewQuery,
|
||||
useIssueSeafarerDocumentMutation,
|
||||
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
||||
@@ -60,6 +61,9 @@ export function SeafarerDocumentReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
|
||||
// The document's policy lives on its licence-type row (the kind is the
|
||||
// type's key), as configured on the Behaviour tab.
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
|
||||
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
|
||||
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
|
||||
@@ -90,6 +94,11 @@ export function SeafarerDocumentReviewPage() {
|
||||
}
|
||||
|
||||
const { document, applicant, payment } = data;
|
||||
// Collected in person unless the type says otherwise: then payment
|
||||
// confirmation issues it and there is nothing to schedule.
|
||||
const collectedInPerson =
|
||||
licenseTypes?.items.find((type) => type.key === document.kind)
|
||||
?.requiresIssuanceScheduling ?? true;
|
||||
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
|
||||
|
||||
@@ -156,12 +165,13 @@ export function SeafarerDocumentReviewPage() {
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{document.status === 'PAYMENT_CONFIRMED' && (
|
||||
{document.status === 'PAYMENT_CONFIRMED' && collectedInPerson && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
|
||||
{(document.status === 'SCHEDULED' ||
|
||||
(document.status === 'PAYMENT_CONFIRMED' && !collectedInPerson)) && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
|
||||
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
|
||||
Issue
|
||||
|
||||
@@ -104,10 +104,7 @@ const UM_CONFIG: DesignConfig = {
|
||||
const UM_RUNTIME = {
|
||||
basename: "/um",
|
||||
// Keep the embedded IAM module on the same API as the backoffice client.
|
||||
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
|
||||
// fall back to its remote development server, where the local JWT is
|
||||
// rejected and the module redirects to its login page.
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "https://ema-api-dev.triaplc.com/api",
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
|
||||
@@ -225,6 +225,8 @@ export const am: Translations = {
|
||||
form: "ቅጽ",
|
||||
venue: "ቦታ",
|
||||
date: "ቀን",
|
||||
window: "የፈተና ሰዓት",
|
||||
allDay: "ቀኑን ሙሉ",
|
||||
administration: "አስተዳደር",
|
||||
evaluation: "ግምገማ",
|
||||
selection: "ምርጫ",
|
||||
@@ -250,6 +252,11 @@ export const am: Translations = {
|
||||
directionAm: "መመሪያ (አማርኛ)",
|
||||
directionAmPlaceholder: "መመሪያ በአማርኛ",
|
||||
examDate: "የፈተና ቀን",
|
||||
startTime: "የመጀመሪያ ሰዓት",
|
||||
endTime: "የመጨረሻ ሰዓት",
|
||||
windowHint:
|
||||
"አማራጭ። ተፈታኞች በፈተናው ቀን ከመጀመሪያ ሰዓት በፊት ወይም ከመጨረሻ ሰዓት በኋላ መጀመር አይችሉም (የአዲስ አበባ ሰዓት)። ቀኑን ሙሉ ክፍት ለማድረግ ባዶ ይተዉ።",
|
||||
windowInvalid: "የመጨረሻ ሰዓት ከመጀመሪያ ሰዓት በኋላ መሆን አለበት።",
|
||||
venue: "ቦታ",
|
||||
venuePlaceholder: "የፈተና ቦታ",
|
||||
timeAllowed: "የተፈቀደ ጊዜ",
|
||||
@@ -308,7 +315,6 @@ export const am: Translations = {
|
||||
COMPLETED: "ተጠናቋል",
|
||||
CANCELLED: "ተሰርዟል",
|
||||
POSTPONED: "ተላልፏል",
|
||||
PUBLISHED: "ታትሟል",
|
||||
},
|
||||
type: {
|
||||
WRITTEN: "ጽሑፍ",
|
||||
@@ -349,6 +355,14 @@ export const am: Translations = {
|
||||
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
|
||||
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
|
||||
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
|
||||
result: "ውጤት",
|
||||
noResult: "አልተመዘነም",
|
||||
engineMarked: "በፈተና ሞተሩ ከመልስ ቁልፉ የተመዘነ — ተቆልፏል። ግምገማ፦ {{review}}።",
|
||||
examinerMarked: "በፈታኝ የተመዘነ። ግምገማ፦ {{review}}።",
|
||||
outcome: {
|
||||
PASSED: "አልፏል",
|
||||
FAILED: "አላለፈም",
|
||||
},
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: "አልተጠራም",
|
||||
@@ -398,6 +412,100 @@ export const am: Translations = {
|
||||
paperLocked: "ወረቀቱ ተቆልፏል",
|
||||
paperLockedHint:
|
||||
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
|
||||
errors: {
|
||||
scoringLocked:
|
||||
"ለዚህ ፈተና {{count}} ውጤት(ዎች) አስቀድመው ጸድቀዋል ወይም ወጥተዋል። የማለፊያ ነጥቡና የግምገማ ዘዴው ሊቀየሩ አይችሉም።",
|
||||
},
|
||||
questionErrors: {
|
||||
subjectMismatch: "አንድ ወይም ከዚያ በላይ ጥያቄዎች የዚህ ፈተና ትምህርት አይደሉም።",
|
||||
notFound: "አንድ ወይም ከዚያ በላይ ጥያቄዎች አልተገኙም።",
|
||||
},
|
||||
questionsMenu: {
|
||||
add: "ጥያቄ ጨምር",
|
||||
fromBank: "ከጥያቄ ባንክ ፍጠር",
|
||||
importExcel: "ከExcel አስገባ",
|
||||
fromScratch: "አዲስ ጥያቄ ጨምር",
|
||||
managePaper: "ሙሉ ወረቀቱን እንደገና መድብ",
|
||||
},
|
||||
bank: {
|
||||
title: "ከጥያቄ ባንክ ጨምር",
|
||||
hint: "ለዚህ ትምህርት የጸደቁና በወረቀቱ ላይ ያልተካተቱ ጥያቄዎች። የተመረጡት ከተመደቡት ጥያቄዎች በኋላ ይጨመራሉ።",
|
||||
search: "ጥያቄዎችን ፈልግ…",
|
||||
empty: "ለዚህ ትምህርት ሊጨመር የሚችል የጸደቀ ጥያቄ የለም።",
|
||||
add: "{{count}} ወደዚህ ፈተና ጨምር",
|
||||
added: "{{count}} ጥያቄ(ዎች) ወደ ወረቀቱ ተጨምረዋል",
|
||||
},
|
||||
newQuestion: {
|
||||
title: "ለዚህ ፈተና አዲስ ጥያቄ ጨምር",
|
||||
hint: "ጥያቄው በዚህ ፈተና ትምህርት ሥር ተፈጥሮ በአንድ እርምጃ በወረቀቱ ላይ ይቀመጣል። እንደ የጸደቀ ጥያቄ ወደ ባንኩ ይገባል።",
|
||||
titleEn: "ጥያቄ (እንግሊዝኛ)",
|
||||
titleAm: "ጥያቄ (አማርኛ)",
|
||||
form: "ዓይነት",
|
||||
points: "ነጥብ",
|
||||
options: "አማራጮች",
|
||||
optionEn: "አማራጭ {{number}} (እንግሊዝኛ)",
|
||||
optionAm: "አማራጭ {{number}} (አማርኛ)",
|
||||
correct: "ትክክል",
|
||||
addOption: "አማራጭ ጨምር",
|
||||
create: "ፍጠርና ወደ ፈተና ጨምር",
|
||||
created: "ጥያቄው ተፈጥሮ ወደ ወረቀቱ ተጨምሯል",
|
||||
fillRequired: "የጥያቄውን ጽሑፍ፣ ዓይነትና ከዜሮ በላይ ነጥብ ያስገቡ።",
|
||||
needTwo: "የምርጫ ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልጉታል።",
|
||||
needCorrect: "ቢያንስ አንድ አማራጭ ትክክል ብለው ይምረጡ።",
|
||||
textRequired: "እያንዳንዱ አማራጭ የእንግሊዝኛ ጽሑፍ ያስፈልገዋል።",
|
||||
},
|
||||
import: {
|
||||
title: "ጥያቄዎችን ከExcel አስገባ",
|
||||
hint: "በእያንዳንዱ ረድፍ አንድ ጥያቄ ያለውን ፋይል ይጫኑ፣ ያረጋግጡ፣ ቅድመ እይታውን ይመልከቱ፣ ከዚያ ያስገቡ። አንድ ረድፍ ችግር ካለው ምንም አይገባም።",
|
||||
template: "ቅጹን አውርድ",
|
||||
file: "የExcel ፋይል (.xlsx)",
|
||||
validate: "አረጋግጥ",
|
||||
import: "አስገባ",
|
||||
preview: "ቅድመ እይታ — {{count}} ጥያቄ(ዎች)",
|
||||
valid: "ለማስገባት ዝግጁ",
|
||||
errors: "{{count}} ችግር(ዎች) ተገኝተዋል",
|
||||
nothingImported: "ከላይ ያሉትን ረድፎች አስተካክለው እንደገና ያረጋግጡ። ምንም አልገባም።",
|
||||
imported: "{{count}} ጥያቄ(ዎች) ወደ ወረቀቱ ገብተዋል",
|
||||
row: "ረድፍ",
|
||||
column: "አምድ",
|
||||
problem: "ችግር",
|
||||
question: "ጥያቄ",
|
||||
type: "ዓይነት",
|
||||
points: "ነጥብ",
|
||||
options: "አማራጮች",
|
||||
errorKeys: {
|
||||
question_text_required: "የጥያቄው ጽሑፍ (እንግሊዝኛ) ያስፈልጋል።",
|
||||
invalid_question_type: "ዓይነት CHOICE ወይም ESSAY መሆን አለበት።",
|
||||
invalid_points: "ነጥብ ከዜሮ የሚበልጥ ቁጥር መሆን አለበት።",
|
||||
options_required: "የምርጫ ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልጉታል (option_a_en, option_b_en, …)።",
|
||||
correct_answer_required: "ትክክለኛውን አማራጭ ፊደል በ“correct” አምድ ውስጥ ያመልክቱ።",
|
||||
correct_answer_invalid: "የ“correct” አምድ ያልተሞላ አማራጭን ያመለክታል።",
|
||||
duplicate_in_file: "ከረድፍ {{detail}} ጋር አንድ ዓይነት ጥያቄ።",
|
||||
duplicate_in_bank: "ይህ ጥያቄ ለዚህ ትምህርት በባንኩ ውስጥ አስቀድሞ አለ።",
|
||||
missing_columns: "የሚያስፈልጉ አምድ(ዎች) ጠፍተዋል፦ {{detail}}።",
|
||||
too_many_rows: "በአንድ ጊዜ ቢበዛ {{detail}} ረድፎች ማስገባት ይቻላል።",
|
||||
no_questions_in_file: "ሉሁ የጥያቄ ረድፍ የለውም።",
|
||||
invalid_excel_file: "ፋይሉ ሊነበብ የሚችል .xlsx አይደለም።",
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
section: "የፈተና መጠበቂያ መለኪያዎች",
|
||||
hint: "ከምዝገባ፣ ከተገኝነትና ከፈተና መጀመሪያ ጊዜ መዝገቦች የተገኘ። ለትንተና ብቻ — ምዝገባን፣ ተገኝነትን፣ ውጤትን ወይም ሰርተፍኬትን አይቀይርም።",
|
||||
candidates: "{{count}} ተመዝግበዋል",
|
||||
attended: "{{count}} ተገኝተዋል",
|
||||
started: "{{count}} ጀምረዋል",
|
||||
registrationToScheduled: "ምዝገባ → የተያዘ መጀመሪያ",
|
||||
scheduledToAttendance: "የተያዘ መጀመሪያ → መግባት",
|
||||
attendanceToExamStart: "መግባት → የፈተና መጀመሪያ",
|
||||
scheduledToExamStart: "የተያዘ መጀመሪያ → የፈተና መጀመሪያ",
|
||||
average: "አማካይ",
|
||||
min: "ዝቅተኛ",
|
||||
max: "ከፍተኛ",
|
||||
count: "ተፈታኞች",
|
||||
minutes: "{{value}} ደቂቃ",
|
||||
hours: "{{value}} ሰዓት",
|
||||
days: "{{value}} ቀን",
|
||||
},
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -618,11 +726,19 @@ export const am: Translations = {
|
||||
descEnPlaceholder: "የእንግሊዝኛ መግለጫ",
|
||||
descAm: "መግለጫ (አማርኛ)",
|
||||
descAmPlaceholder: "የአማርኛ መግለጫ",
|
||||
rankKey: "የSTCW ማዕረግ (ለፈተና መርሐግብር)",
|
||||
rankKeyHint: "ይህ የምስክር ወረቀት የሚፈተነው የCoC/CoP መሰላል አካል ካልሆነ ባዶ ይተዉት።",
|
||||
rankKeyPlaceholder: "ለተወሰነ ማዕረግ አይደለም",
|
||||
isActive: "ንቁ",
|
||||
isActiveHint: "ንቁ ያልሆኑ የምስክር ወረቀቶች በነባር ፈተናዎች ላይ ይቆያሉ፤ ለአዲስ ፈተናዎች ግን አይቀርቡም።",
|
||||
},
|
||||
status: {
|
||||
active: "ንቁ",
|
||||
inactive: "እንቅስቃሴ የሌለ",
|
||||
},
|
||||
validation: {
|
||||
nameRequired: "የእንግሊዝኛ እና የአማርኛ ስሞች ሁለቱም ያስፈልጋሉ",
|
||||
},
|
||||
},
|
||||
|
||||
result: {
|
||||
@@ -698,6 +814,19 @@ export const am: Translations = {
|
||||
remark: "ማስታወሻ",
|
||||
remarkOptional: "ማስታወሻ (አማራጭ)",
|
||||
remarkPlaceholder: "የኦፊሰር ማስታወሻ",
|
||||
reason: "ምክንያት / አስተያየት",
|
||||
reasonPlaceholder: "ተፈታኙ እነዚህ ነጥቦች የተሰጡበት ምክንያት — ግዴታ",
|
||||
reasonRequired: "በእጅ ለሚመዘገብ ውጤት ምክንያት ያስፈልጋል።",
|
||||
scorePlaceholder: "ነጥብ",
|
||||
scoresRequired: "{{count}} ጥያቄ(ዎች) እስካሁን ነጥብ የላቸውም። እያንዳንዱ ጥያቄ ነጥብ ያስፈልገዋል — ባዶ ሳጥን ዜሮ አይደለም።",
|
||||
alreadyMarkedHint:
|
||||
"በዚህ ፈተና {{count}} ተፈታኝ(ዎች) አስቀድመው ውጤት አላቸውና አልተዘረዘሩም — የፈተና ሞተሩ ወይም ፈታኝ ወረቀታቸውን መዝኗል። እነዚያን ለመመልከት የፈተና ውጤቶችን ይጠቀሙ።",
|
||||
allMarked: "በዚህ ፈተና የተፈተኑ ሁሉ አስቀድመው ውጤት አላቸው። በእጅ የሚመዘገብ ምንም የለም።",
|
||||
errors: {
|
||||
notRegistered: "ይህ ተፈታኝ ለዚህ ፈተና አልተመዘገበም።",
|
||||
notPresent: "የሚመዘን ወረቀት የለም — መዝገቡ {{ruling}} ይላል።",
|
||||
alreadyRecorded: "ለዚህ ተፈታኝ ውጤት አስቀድሞ ተመዝግቧል።",
|
||||
},
|
||||
totalScore: "ጠቅላላ ውጤት",
|
||||
passMark: "ማለፊያ ውጤት",
|
||||
status: "ሁኔታ",
|
||||
@@ -714,6 +843,7 @@ export const am: Translations = {
|
||||
},
|
||||
action: {
|
||||
viewEdit: "ተመልከት / አስተካክል",
|
||||
view: "ተመልከት",
|
||||
delete: "ሰርዝ",
|
||||
},
|
||||
search: {
|
||||
@@ -763,6 +893,15 @@ export const am: Translations = {
|
||||
publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።",
|
||||
publishConfirmText:
|
||||
"ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?",
|
||||
publishOne: "ይህን ውጤት አውጣ",
|
||||
publishOneConfirmText:
|
||||
"የ{{candidate}} የ{{exam}} ውጤት ይውጣ? ይህ ተፈታኝ ብቻ ይነገረዋል፣ የእሱ/የእሷ ማመልከቻ ብቻ ይቀጥላል — ፈተናውና ሌሎች ተፈታኞች አይነኩም።",
|
||||
publishedOne: "ውጤቱ ለተፈታኙ ወጥቷል",
|
||||
publishNeedsApproval: "ውጤት ከመውጣቱ በፊት መጽደቅ አለበት።",
|
||||
alreadyPublished: "ይህ ውጤት አስቀድሞ ወጥቷል።",
|
||||
autoGraded: "በራስ-ሰር የተገመገመ",
|
||||
autoGradedLocked:
|
||||
"ይህ ውጤት በፈተና ሞተሩ ከተፈታኙ መልሶችና ከመልስ ቁልፉ ተሰልቷል። ተቆልፏል፦ ነጥቦች ሊስተካከሉ፣ ሊመረመሩ ወይም ሊሰረዙ አይችሉም። የተቀበለ ይግባኝ ብቻ ለድጋሚ እርማት ይከፍተዋል።",
|
||||
lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።",
|
||||
originalScore: "የፈታኙ ጠቅላላ",
|
||||
derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)",
|
||||
@@ -874,6 +1013,88 @@ export const am: Translations = {
|
||||
|
||||
configuration: {
|
||||
title: "ውቅረት",
|
||||
licenseTypesTab: "የፈቃድ አይነቶች",
|
||||
licenseTypes: {
|
||||
add: "የፈቃድ ዓይነት ያክሉ",
|
||||
edit: "የፈቃድ ዓይነት አስተካክል",
|
||||
key: "ቁልፍ",
|
||||
keyHint:
|
||||
"ቋሚ መለያ፣ ለምሳሌ CUSTOMS_BROKER። ማመልከቻዎች ከጠቀሱት በኋላ ሊቀየር አይችልም።",
|
||||
keyInvalid: "አቢይ ፊደላት፣ አሃዞችና ከስር መስመር፣ ከ3 እስከ 64 ቁምፊዎች",
|
||||
category: "ምድብ",
|
||||
categoryHint:
|
||||
"እነማን ማመልከት እንደሚችሉና የትኞቹ የሹመት ደረጃዎች እርምጃ መውሰድ እንደሚችሉ ይወስናል።",
|
||||
familyKind: "ዓይነት",
|
||||
familyKindHint:
|
||||
"ፈቃድ፣ የምስክር ወረቀት ወይም ሰነድ። አጠቃቀሙን፣ የአመልካች ካታሎጉንና ወረፋው ድርጅት እንደሚያሳይ ይወስናል።",
|
||||
prefix: "የምስክር ወረቀት ቅድመ ቅጥያ",
|
||||
prefixHint:
|
||||
"የእያንዳንዱ ማመልከቻና የምስክር ወረቀት ቁጥር መጀመሪያ፣ ለምሳሌ CB → CB-2026-000042።",
|
||||
prefixInvalid: "ያስፈልጋል፣ ቢበዛ 12 ቁምፊዎች",
|
||||
status: "ሁኔታ",
|
||||
active: "ንቁ",
|
||||
inactive: "እንቅስቃሴ የሌለ",
|
||||
isActive: "ለአመልካቾች ይታያል",
|
||||
isActiveHint: "ቅጹና የሰነድ መስፈርቶቹ እስኪዘጋጁ ድረስ አጥፍተው ይተዉት።",
|
||||
activate: "አንቃ",
|
||||
deactivate: "አሰናክል",
|
||||
activateTitle: "የፈቃድ ዓይነት አንቃ",
|
||||
deactivateTitle: "የፈቃድ ዓይነት አሰናክል",
|
||||
activateText: "{{name}} ለአመልካቾች እንደገና ይቀርባል።",
|
||||
deactivateText:
|
||||
"{{name}} ከአመልካች ካታሎግ ይጠፋል። በሂደት ላይ ያሉ ማመልከቻዎች ሳይነኩ ይቀጥላሉ።",
|
||||
activated: "የፈቃድ ዓይነቱ አሁን ማመልከቻዎችን ይቀበላል",
|
||||
deactivated: "የፈቃድ ዓይነቱ ለአዲስ ማመልከቻዎች ተዘግቷል",
|
||||
noPermission: "የፈቃድ ዓይነት ለመፍጠር ፈቃድ የለዎትም።",
|
||||
nextStepsTitle: "ዓይነቱን ከፈጠሩ በኋላ",
|
||||
nextSteps:
|
||||
"ቅጹን፣ የሰነድ መስፈርቶቹንና ባህሪውን በምስክር ወረቀት መስፈርቶች፣ ክፍያዎቹን በክፍያ ውቅረት፣ የምስክር ወረቀት ንድፉን ደግሞ በንድፍ ሰሪው ያዘጋጁ — ከዚያ እዚህ ንቁ ያድርጉት።",
|
||||
empty: "እስካሁን ምንም የፈቃድ አይነቶች የሉም",
|
||||
notice:
|
||||
"አዲስ አይነት ባዶ ሆኖ ይጀምራል። ከፈጠሩ በኋላ ቅጹንና የሰነድ መስፈርቶቹን በምስክር ወረቀት መስፈርቶች ስር፣ ክፍያዎቹን ደግሞ በክፍያ ውቅረት ስር ያዘጋጁ።",
|
||||
goToRequirements: "የምስክር ወረቀት መስፈርቶች",
|
||||
goToFees: "የክፍያ ውቅረት",
|
||||
created: "የፈቃድ አይነት ተፈጥሯል። በመቀጠል ቅጹን፣ ሰነዶቹንና ክፍያዎቹን ያዘጋጁ።",
|
||||
toggleAria: "ይህ የፈቃድ አይነት ማመልከቻ መቀበል አለመቀበሉን ይቀያይሩ",
|
||||
familyHint: "የትኛው ክፍል እንደሚያስተዳድረው እና በየትኛው የፖርታል ካታሎግ እንደሚታይ ይወስናል።",
|
||||
fee: "የአዲስ ማመልከቻ ክፍያ",
|
||||
feeHint: "ክፍያ ከሌለው ባዶ ይተዉት።",
|
||||
currency: "ምንዛሪ",
|
||||
validity: "የአገልግሎት ጊዜ (በወራት)",
|
||||
issuesCertificate: "የምስክር ወረቀት ይሰጣል",
|
||||
renewalEnabled: "ሊታደስ የሚችል",
|
||||
inspectionRequired: "ምርመራ ያስፈልጋል",
|
||||
columns: {
|
||||
category: "ምድብ",
|
||||
family: "ቤተሰብ",
|
||||
prefix: "ቅድመ ቅጥያ",
|
||||
status: "ሁኔታ",
|
||||
actions: "እርምጃዎች",
|
||||
},
|
||||
family: {
|
||||
LOGISTICS_LICENSE: "የሎጂስቲክስ ፈቃድ",
|
||||
CERTIFICATE: "የባህርተኛ የምስክር ወረቀት",
|
||||
DOCUMENT: "የማንነት / ህጋዊ ሰነድ",
|
||||
},
|
||||
service: {
|
||||
LICENSE: "ፈቃድ",
|
||||
REGISTRATION: "ምዝገባ",
|
||||
},
|
||||
workflow: {
|
||||
STANDARD: "መደበኛ (ግምገማ → ምዘና → ምርመራ → ማጽደቅ)",
|
||||
REGISTRATION: "ምዝገባ (ግምገማ → ማጽደቅ)",
|
||||
},
|
||||
validation: {
|
||||
keyRequired: "ቁልፍ ያስፈልጋል",
|
||||
keyTooLong: "ቁልፍ ከ64 ቁምፊዎች መብለጥ የለበትም",
|
||||
keyFormat: "ፊደላት፣ አሃዞችና ከስር መስመር ብቻ ይጠቀሙ፣ ለምሳሌ PORT_AGENT",
|
||||
keyTaken: "በዚህ ቁልፍ የፈቃድ አይነት ቀድሞ አለ",
|
||||
prefixRequired: "የምስክር ወረቀት ቅድመ ቅጥያ ያስፈልጋል",
|
||||
prefixTooLong: "ቅድመ ቅጥያ ከ12 ቁምፊዎች መብለጥ የለበትም",
|
||||
currencyTooLong: "አጭር የምንዛሪ ኮድ ይጠቀሙ",
|
||||
validityRange: "የአገልግሎት ጊዜ በ1 እና 240 ወራት መካከል መሆን አለበት",
|
||||
},
|
||||
},
|
||||
personalDocumentsTab: "የግል ሰነዶች",
|
||||
departments: "ክፍሎች",
|
||||
professions: "ሙያዎች",
|
||||
@@ -909,6 +1130,7 @@ export const am: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
queue: {
|
||||
title: "የፈቃድ ማመልከቻዎች",
|
||||
titleByFamily: "{{family}} ማመልከቻዎች",
|
||||
@@ -1029,6 +1251,15 @@ export const am: Translations = {
|
||||
deficienciesHint: "የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።",
|
||||
notificationPreview: "ለአመልካቹ የሚላክ መልእክት",
|
||||
notificationPreviewHint: "በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።",
|
||||
corrected: {
|
||||
title_one: "በዙር {{round}} 1 ነጥብ ተስተካክሏል",
|
||||
title_other: "በዙር {{round}} {{count}} ነጥቦች ተስተካክለዋል",
|
||||
untouchedBadge_one: "1 አልተለወጠም",
|
||||
untouchedBadge_other: "{{count}} አልተለወጡም",
|
||||
untouched: "እንደተጠናቀቀ ምልክት ተደርጎ ግን አልተለወጠም — መጀመሪያ እነዚህን ይመልከቱ",
|
||||
against: "የተጠየቀው፦ {{remark}}",
|
||||
badge: "ተስተካክሏል",
|
||||
},
|
||||
needsCorrection: "ማስተካከያ ያስፈልገዋል",
|
||||
correctionPlaceholder: "አመልካቹ ምን ማስተካከል አለበት?",
|
||||
verifiedCapital: "የተረጋገጠ ካፒታል (ብር)",
|
||||
@@ -1111,6 +1342,7 @@ export const am: Translations = {
|
||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||
inspectionFailed: "ምርመራው አልተሳካም — ከመጽደቁ በፊት ድጋሚ ምርመራ ማለፍ አለበት",
|
||||
inspectionNotYetDue:
|
||||
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
|
||||
needsDocumentReviews:
|
||||
@@ -1118,7 +1350,9 @@ export const am: Translations = {
|
||||
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
||||
},
|
||||
inspectionFailedBlocked:
|
||||
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
||||
"ምርመራው ስላልተሳካ ማጽደቅና ሰርተፍኬት መስጠት አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
||||
checklistMustPass:
|
||||
"ምርመራው እንደተሳካ ከመመዝገቡ በፊት ሁሉም ነጥቦች ማለፍ አለባቸው።",
|
||||
reasons: {
|
||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
|
||||
@@ -1164,6 +1398,7 @@ export const am: Translations = {
|
||||
officer: "ሹም",
|
||||
applicant: "አመልካች",
|
||||
remarkOn: "በ{{target}} ላይ ማስተካከያ ተጠይቋል",
|
||||
correctedTarget: "{{target}} ተስተካክሏል",
|
||||
uploaded: "{{document}} ተጭኗል",
|
||||
},
|
||||
documents: {
|
||||
@@ -1526,6 +1761,21 @@ export const am: Translations = {
|
||||
maxFilesUnlimited: "ገደብ የለም",
|
||||
sortOrder: "የቅደም ተከተል ቁጥር",
|
||||
when: "መቼ",
|
||||
importPersonal: "ከግል ሰነዶች አስመጣ",
|
||||
importPersonalDesc:
|
||||
"ለዚህ የፈቃድ አይነት እንደ መስፈርት ለማከል በውቅር ገጽ ላይ ከተዋቀሩት የግል ሰነዶች ይምረጡ።",
|
||||
importFromPersonal: "ከግል ሰነድ አስመጣ",
|
||||
selectPersonalDoc: "ዝርዝሩን ለመቅዳት የግል ሰነድ ይምረጡ…",
|
||||
importSelected: "የተመረጡትን አስመጣ ({{count}})",
|
||||
importAction: "አስመጣ",
|
||||
customizeAndAdd: "አስተካክለህ ጨምር",
|
||||
alreadyAdded: "ቀደም ሲል ተጨምሯል",
|
||||
noPersonalDocs: "በውቅር ውስጥ የተዘጋጀ የግል ሰነድ እስካሁን የለም።",
|
||||
importedFromPersonal: "ከግል ሰነድ የመጣ: {{name}}",
|
||||
importedHelper:
|
||||
"ከግል ሰነዶች የመጣ። ቁልፉ ከአመልካቹ ሰነዶች ጋር ስለሚዛመድ ፋይሎች በቀጥታ ይገናኛሉ።",
|
||||
importMode: "ለሚመጡ ሰነዶች የመስፈርት ሁነታ",
|
||||
importSuccess: "{{count}} የግል ሰነድ(ዶች) ገብተዋል",
|
||||
},
|
||||
personal: {
|
||||
title: "የግል ሰነዶች",
|
||||
|
||||
@@ -224,6 +224,8 @@ export const en = {
|
||||
form: 'Form',
|
||||
venue: 'Venue',
|
||||
date: 'Date',
|
||||
window: 'Session time',
|
||||
allDay: 'All day',
|
||||
administration: 'Administration',
|
||||
evaluation: 'Evaluation',
|
||||
selection: 'Selection',
|
||||
@@ -249,6 +251,11 @@ export const en = {
|
||||
directionAm: 'Direction (Amharic)',
|
||||
directionAmPlaceholder: 'መመሪያ በአማርኛ',
|
||||
examDate: 'Exam Date',
|
||||
startTime: 'Start time',
|
||||
endTime: 'End time',
|
||||
windowHint:
|
||||
'Optional. Candidates cannot start before the start time, or after the end time, on the exam date (Addis Ababa time). Leave blank to open the whole day.',
|
||||
windowInvalid: 'The end time must be after the start time.',
|
||||
venue: 'Venue',
|
||||
venuePlaceholder: 'Exam venue',
|
||||
timeAllowed: 'Time Allowed',
|
||||
@@ -306,7 +313,6 @@ export const en = {
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
POSTPONED: 'Postponed',
|
||||
PUBLISHED: 'Published',
|
||||
},
|
||||
type: {
|
||||
WRITTEN: 'Written',
|
||||
@@ -347,6 +353,14 @@ export const en = {
|
||||
regraded: 'Result created from the graded attempt.',
|
||||
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
|
||||
regradeError: 'Could not regrade this attempt.',
|
||||
result: 'Result',
|
||||
noResult: 'Not marked',
|
||||
engineMarked: 'Marked by the exam engine from the answer key — locked. Review: {{review}}.',
|
||||
examinerMarked: 'Marked by an examiner. Review: {{review}}.',
|
||||
outcome: {
|
||||
PASSED: 'Passed',
|
||||
FAILED: 'Failed',
|
||||
},
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'Not called',
|
||||
@@ -397,6 +411,100 @@ export const en = {
|
||||
paperLocked: 'Paper locked',
|
||||
paperLockedHint:
|
||||
'{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
|
||||
errors: {
|
||||
scoringLocked:
|
||||
'{{count}} result(s) for this session are already approved or published. The pass mark and evaluation method cannot change under them.',
|
||||
},
|
||||
questionErrors: {
|
||||
subjectMismatch: 'One or more questions belong to a different subject than this exam.',
|
||||
notFound: 'One or more questions could not be found.',
|
||||
},
|
||||
questionsMenu: {
|
||||
add: 'Add question',
|
||||
fromBank: 'Create from question bank',
|
||||
importExcel: 'Import from Excel',
|
||||
fromScratch: 'Add new question',
|
||||
managePaper: 'Reassign whole paper',
|
||||
},
|
||||
bank: {
|
||||
title: 'Add from the question bank',
|
||||
hint: 'Approved questions for this subject that are not yet on the paper. Selected items are added after the questions already assigned.',
|
||||
search: 'Search questions…',
|
||||
empty: 'No approved questions for this subject are available to add.',
|
||||
add: 'Add {{count}} to this exam',
|
||||
added: '{{count}} question(s) added to the paper',
|
||||
},
|
||||
newQuestion: {
|
||||
title: 'Add a new question to this exam',
|
||||
hint: 'The question is created under this exam’s subject and placed on its paper in one step. It joins the bank as an approved item.',
|
||||
titleEn: 'Question (English)',
|
||||
titleAm: 'Question (Amharic)',
|
||||
form: 'Type',
|
||||
points: 'Points',
|
||||
options: 'Options',
|
||||
optionEn: 'Option {{number}} (English)',
|
||||
optionAm: 'Option {{number}} (Amharic)',
|
||||
correct: 'Correct',
|
||||
addOption: 'Add option',
|
||||
create: 'Create and add to exam',
|
||||
created: 'Question created and added to the paper',
|
||||
fillRequired: 'Enter the question text, type and a positive number of points.',
|
||||
needTwo: 'A choice question needs at least two options.',
|
||||
needCorrect: 'Mark at least one option as correct.',
|
||||
textRequired: 'Every option needs its English text.',
|
||||
},
|
||||
import: {
|
||||
title: 'Import questions from Excel',
|
||||
hint: 'Upload a workbook with one question per row, validate it, review the preview, then import. If any row has a problem, nothing is imported.',
|
||||
template: 'Download the template',
|
||||
file: 'Excel file (.xlsx)',
|
||||
validate: 'Validate',
|
||||
import: 'Import',
|
||||
preview: 'Preview — {{count}} question(s)',
|
||||
valid: 'Ready to import',
|
||||
errors: '{{count}} problem(s) found',
|
||||
nothingImported: 'Fix the rows above and validate again. Nothing has been imported.',
|
||||
imported: 'Imported {{count}} question(s) onto the paper',
|
||||
row: 'Row',
|
||||
column: 'Column',
|
||||
problem: 'Problem',
|
||||
question: 'Question',
|
||||
type: 'Type',
|
||||
points: 'Points',
|
||||
options: 'Options',
|
||||
errorKeys: {
|
||||
question_text_required: 'The question text (English) is required.',
|
||||
invalid_question_type: 'Type must be CHOICE or ESSAY.',
|
||||
invalid_points: 'Points must be a number greater than zero.',
|
||||
options_required: 'A CHOICE question needs at least two options (option_a_en, option_b_en, …).',
|
||||
correct_answer_required: 'Mark the correct option letter(s) in the “correct” column.',
|
||||
correct_answer_invalid: 'The “correct” column names an option that is not filled in.',
|
||||
duplicate_in_file: 'Same question as row {{detail}}.',
|
||||
duplicate_in_bank: 'This question already exists in the bank for this subject.',
|
||||
missing_columns: 'Required column(s) missing: {{detail}}.',
|
||||
too_many_rows: 'At most {{detail}} rows can be imported at once.',
|
||||
no_questions_in_file: 'The sheet has no question rows.',
|
||||
invalid_excel_file: 'The file is not a readable .xlsx workbook.',
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
section: 'Exam wait metrics',
|
||||
hint: 'Derived from the registration, attendance and exam-start timestamps already on record. Analytical only — nothing here changes a registration, attendance, result or certificate.',
|
||||
candidates: '{{count}} registered',
|
||||
attended: '{{count}} checked in',
|
||||
started: '{{count}} started',
|
||||
registrationToScheduled: 'Registration → scheduled start',
|
||||
scheduledToAttendance: 'Scheduled start → check-in',
|
||||
attendanceToExamStart: 'Check-in → exam start',
|
||||
scheduledToExamStart: 'Scheduled start → exam start',
|
||||
average: 'average',
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
count: 'candidates',
|
||||
minutes: '{{value}} min',
|
||||
hours: '{{value}} h',
|
||||
days: '{{value}} d',
|
||||
},
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -610,6 +718,13 @@ export const en = {
|
||||
description: 'Description',
|
||||
status: 'Status',
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: 'Both English and Amharic names are required',
|
||||
},
|
||||
form: {
|
||||
nameEn: 'Name (English)',
|
||||
nameEnPlaceholder: 'Certificate name in English',
|
||||
@@ -619,10 +734,11 @@ export const en = {
|
||||
descEnPlaceholder: 'English description',
|
||||
descAm: 'Description (Amharic)',
|
||||
descAmPlaceholder: 'የአማርኛ መግለጫ',
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
rankKey: 'STCW rank (for exam scheduling)',
|
||||
rankKeyHint: 'Leave blank if this certification is not part of the examined CoC/CoP ladder.',
|
||||
rankKeyPlaceholder: 'Not rank-specific',
|
||||
isActive: 'Active',
|
||||
isActiveHint: 'Inactive certifications stay on existing exams but are not offered for new ones.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -699,6 +815,19 @@ export const en = {
|
||||
remark: 'Remark',
|
||||
remarkOptional: 'Remark (optional)',
|
||||
remarkPlaceholder: 'Officer remarks',
|
||||
reason: 'Reason / remarks',
|
||||
reasonPlaceholder: 'Why the candidate is awarded these marks — required',
|
||||
reasonRequired: 'A reason is required for a manually recorded result.',
|
||||
scorePlaceholder: 'Mark',
|
||||
scoresRequired: '{{count}} question(s) have no mark yet. Every question needs a mark — an empty box is not a zero.',
|
||||
alreadyMarkedHint:
|
||||
'{{count}} candidate(s) on this session already have a result and are not listed — the exam engine or an examiner has marked their paper. Use Exam Results to review those.',
|
||||
allMarked: 'Every candidate who sat this session already has a result. There is nothing left to record by hand.',
|
||||
errors: {
|
||||
notRegistered: 'This candidate is not registered for the session.',
|
||||
notPresent: 'No paper to mark — the register says {{ruling}}.',
|
||||
alreadyRecorded: 'A result has already been recorded for this candidate.',
|
||||
},
|
||||
totalScore: 'Total Score',
|
||||
passMark: 'Pass Mark',
|
||||
status: 'Status',
|
||||
@@ -715,6 +844,7 @@ export const en = {
|
||||
},
|
||||
action: {
|
||||
viewEdit: 'View / Edit',
|
||||
view: 'View',
|
||||
delete: 'Delete',
|
||||
},
|
||||
search: {
|
||||
@@ -765,6 +895,15 @@ export const en = {
|
||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||
publishConfirmText:
|
||||
'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?',
|
||||
publishOne: 'Publish this result',
|
||||
publishOneConfirmText:
|
||||
'Publish the result of {{candidate}} for {{exam}}? Only this candidate is notified and only their application advances — the exam and every other candidate are unaffected.',
|
||||
publishedOne: 'Result published to the candidate',
|
||||
publishNeedsApproval: 'A result must be approved before it can be published.',
|
||||
alreadyPublished: 'This result has already been published.',
|
||||
autoGraded: 'Auto-graded',
|
||||
autoGradedLocked:
|
||||
'This mark was calculated by the exam engine from the candidate’s answers and the answer key. It is locked: scores cannot be edited, moderated or deleted. Only an upheld appeal reopens it for re-marking.',
|
||||
lockedAfterApproval:
|
||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||
originalScore: 'Examiner total',
|
||||
@@ -879,6 +1018,89 @@ export const en = {
|
||||
|
||||
configuration: {
|
||||
title: 'Configuration',
|
||||
licenseTypesTab: 'Licence Types',
|
||||
licenseTypes: {
|
||||
add: 'Add licence type',
|
||||
edit: 'Edit licence type',
|
||||
key: 'Key',
|
||||
keyHint:
|
||||
'Permanent identifier, e.g. CUSTOMS_BROKER. Cannot be changed once applications reference it.',
|
||||
keyInvalid: 'Upper-case letters, digits and underscores, 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',
|
||||
departments: 'Departments',
|
||||
professions: 'Professions',
|
||||
@@ -1039,6 +1261,15 @@ export const en = {
|
||||
deficienciesHint: 'Only the ticked items become editable for the applicant.',
|
||||
notificationPreview: 'Message to the applicant',
|
||||
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
|
||||
corrected: {
|
||||
title_one: '1 item corrected in round {{round}}',
|
||||
title_other: '{{count}} items corrected in round {{round}}',
|
||||
untouchedBadge_one: '1 unchanged',
|
||||
untouchedBadge_other: '{{count}} unchanged',
|
||||
untouched: 'Marked done but left unchanged — re-read these first',
|
||||
against: 'Asked: {{remark}}',
|
||||
badge: 'Corrected',
|
||||
},
|
||||
needsCorrection: 'Needs correction',
|
||||
correctionPlaceholder: 'What must the applicant correct?',
|
||||
verifiedCapital: 'Verified capital (ETB)',
|
||||
@@ -1121,6 +1352,8 @@ export const en = {
|
||||
needsFlags: 'Flag at least one item to request a correction',
|
||||
needsCapital: 'Record the verified capital first',
|
||||
needsInspection: 'Requires an inspection result',
|
||||
inspectionFailed:
|
||||
'The inspection failed — a re-inspection must pass before approval',
|
||||
inspectionNotYetDue:
|
||||
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
|
||||
needsDocumentReviews:
|
||||
@@ -1128,7 +1361,9 @@ export const en = {
|
||||
needsDocumentsUploaded: 'No documents uploaded to review yet',
|
||||
},
|
||||
inspectionFailedBlocked:
|
||||
'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
|
||||
'Approval and certificate issuance are unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
|
||||
checklistMustPass:
|
||||
'Every item must pass before the inspection can be recorded as passed.',
|
||||
reasons: {
|
||||
incompleteDocuments: 'Incomplete documents',
|
||||
belowCapital: 'Capital below the required minimum',
|
||||
@@ -1171,6 +1406,7 @@ export const en = {
|
||||
officer: 'Officer',
|
||||
applicant: 'Applicant',
|
||||
remarkOn: 'Correction requested on {{target}}',
|
||||
correctedTarget: 'Corrected {{target}}',
|
||||
uploaded: 'Uploaded {{document}}',
|
||||
},
|
||||
documents: {
|
||||
@@ -1533,6 +1769,21 @@ export const en = {
|
||||
maxFilesUnlimited: 'No limit',
|
||||
sortOrder: 'Sort order',
|
||||
when: 'when',
|
||||
importPersonal: 'Import from personal documents',
|
||||
importPersonalDesc:
|
||||
'Select documents configured in Personal Documents to add as requirements for this licence type.',
|
||||
importFromPersonal: 'Import from personal document',
|
||||
selectPersonalDoc: 'Select a personal document to copy details…',
|
||||
importSelected: 'Import selected ({{count}})',
|
||||
importAction: 'Import',
|
||||
customizeAndAdd: 'Customize & add',
|
||||
alreadyAdded: 'Already added',
|
||||
noPersonalDocs: 'No personal documents configured in Configuration yet.',
|
||||
importedFromPersonal: 'Imported from personal document: {{name}}',
|
||||
importedHelper:
|
||||
'Imported from personal documents. The key matches the vault so uploaded files will link automatically.',
|
||||
importMode: 'Requirement mode for imported documents',
|
||||
importSuccess: 'Imported {{count}} personal document(s)',
|
||||
},
|
||||
personal: {
|
||||
title: 'Personal documents',
|
||||
|
||||
@@ -14,8 +14,9 @@ export default defineConfig({
|
||||
host: 'localhost',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://ema-api-dev.triaplc.com',
|
||||
target: process.env.VITE_API_PROXY_TARGET || 'https://ema-api-dev.triaplc.com',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user