Initial End to End functionality

This commit is contained in:
Mulu Mehari
2026-08-02 22:44:08 +03:00
parent c9d885356c
commit c62ec59655
53 changed files with 6791 additions and 1208 deletions

View File

@@ -0,0 +1,560 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Code,
Container,
Group,
Modal,
NumberInput,
Paper,
ScrollArea,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconDeviceFloppy,
IconEye,
IconPlus,
IconRosetteDiscountCheck,
IconTrash,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
extractErrorMessage,
useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
type LicenseTemplate,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth';
import { PERMISSIONS } from '../../../layouts/nav-config';
/** Same resolution the shared RTK Query baseQuery uses. */
const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',
PUBLISHED: 'teal',
ARCHIVED: 'dark',
};
/**
* Where the authority designs the certificate its licensees receive.
*
* The layout used to be a Handlebars file inside the deployed image, so any
* change to the authority's own certificate needed a developer and a release.
* Here it is data: staff author a version, preview the real PDF, and publish.
* Publishing archives the incumbent, so exactly one design is live per licence
* type and previously issued certificates keep the design they were made from.
*/
export function CertificateDesignerPage() {
const { t } = useTranslation();
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const {
data: templates = [],
isLoading,
isError,
error,
refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery();
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [source, setSource] = useState('');
const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const editorRef = useRef<HTMLTextAreaElement>(null);
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
const [validityMonths, setValidityMonths] = useState<number>(12);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
useEffect(() => {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
const selected = useMemo(
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
[templates, selectedId],
);
// Pick the live design by default — that is the one staff usually want.
useEffect(() => {
if (!templates.length) {
setSelectedId(null);
return;
}
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
setSelectedId((published ?? templates[0]).id);
}, [templates, selectedId]);
useEffect(() => {
if (!selected) return;
setSource(selected.hbsSource);
setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true);
}, [selected]);
const isPublished = selected?.status === 'PUBLISHED';
const dirty =
Boolean(selected) &&
(source !== selected?.hbsSource ||
name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true));
async function run(action: () => Promise<unknown>, success: string) {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}
/** Inserts a placeholder where the caret is, rather than at the end. */
function insertVariable(key: string) {
const el = editorRef.current;
const token = `{{${key}}}`;
if (!el) {
setSource((prev) => prev + token);
return;
}
const start = el.selectionStart ?? source.length;
const end = el.selectionEnd ?? start;
setSource(source.slice(0, start) + token + source.slice(end));
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
}
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
*/
async function preview() {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
// and calls the API directly — which means spelling out the base URL and
// the bearer token that the shared baseQuery would normally attach.
const token = authStorage.getToken();
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
hbsSource: source,
licenseTypeId: typeId,
pageOptions: { format: 'A4', landscape, printBackground: true },
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.previewFailed', 'Could not render the preview'),
message: extractErrorMessage(err),
});
}
}
return (
<Container size="xl" py="md">
<PageHeader
title={t('designer.title', 'Certificate designer')}
subtitle={t(
'designer.subtitle',
'Design the certificate issued to licence holders, and set how long it stays valid.',
)}
/>
<Group align="flex-end" mb="md" gap="sm">
<Select
label={t('designer.licenceType', 'Licence type')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name?.en ?? type.key,
}))}
value={typeId}
onChange={(value) => {
setTypeId(value);
setSelectedId(null);
}}
w={280}
/>
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
onClick={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
<Button
leftSection={<IconPlus size={16} />}
disabled={!canEdit || !typeId}
onClick={() => {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
setNewOpen(true);
}}
>
{t('designer.newVersion', 'New version')}
</Button>
</Group>
{isError ? (
<ErrorState
title={t('designer.loadFailed', 'Could not load the designs')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
) : !isLoading && templates.length === 0 ? (
<EmptyState
title={t('designer.empty', 'No design yet for this licence type')}
description={t(
'designer.emptyBody',
'Certificates currently use the built-in layout. Create a version to take control of it.',
)}
action={
canEdit
? {
label: t('designer.newVersion', 'New version'),
onClick: () => {
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
setNewOpen(true);
},
}
: undefined
}
/>
) : (
<Group align="flex-start" gap="md" wrap="nowrap">
{/* Versions */}
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.versions', 'Versions')}
</Text>
{templates.map((tpl) => (
<Card
key={tpl.id}
withBorder
padding="xs"
onClick={() => setSelectedId(tpl.id)}
style={{
cursor: 'pointer',
borderColor:
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
}}
>
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{tpl.name}
</Text>
<Text size="xs" c="dimmed">
v{tpl.version}
</Text>
</div>
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
{tpl.status}
</Badge>
</Group>
</Card>
))}
</Stack>
{/* Editor */}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" align="flex-end">
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
disabled={!canEdit || isPublished}
style={{ flex: 1 }}
/>
<Switch
label={t('designer.landscape', 'Landscape')}
checked={landscape}
onChange={(e) => setLandscape(e.currentTarget.checked)}
disabled={!canEdit || isPublished}
/>
</Group>
{isPublished && (
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
<Text size="xs">
{t(
'designer.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
)}
</Text>
</Paper>
)}
<Textarea
ref={editorRef}
label={t('designer.source', 'Template (Handlebars + HTML)')}
value={source}
onChange={(e) => setSource(e.currentTarget.value)}
disabled={!canEdit || isPublished}
autosize
minRows={18}
maxRows={30}
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
/>
<Group>
<Button
variant="light"
leftSection={<IconEye size={16} />}
onClick={preview}
disabled={!source.trim()}
>
{t('designer.preview', 'Preview PDF')}
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
loading={saving}
disabled={!canEdit || isPublished || !dirty}
onClick={() =>
run(
() =>
updateTemplate({
id: selected!.id,
name,
hbsSource: source,
pageOptions: { format: 'A4', landscape, printBackground: true },
}).unwrap(),
t('designer.saved', 'Draft saved'),
)
}
>
{t('designer.save', 'Save draft')}
</Button>
<Tooltip
label={
!canPublish
? t('designer.noPublishPermission', 'You cannot publish designs')
: dirty
? t('designer.saveFirst', 'Save your changes first')
: t('designer.publishHint', 'Makes this the live certificate design')
}
>
<span>
<Button
color="teal"
leftSection={<IconRosetteDiscountCheck size={16} />}
loading={publishing}
disabled={!canPublish || isPublished || dirty || !selected}
onClick={() =>
run(
() => publishTemplate(selected!.id).unwrap(),
t('designer.published', 'Design published'),
)
}
>
{t('designer.publish', 'Publish')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
{selected && isPublished && canPublish && (
<Button
variant="subtle"
color="orange"
onClick={() =>
run(
() => archiveTemplate(selected.id).unwrap(),
t('designer.archived', 'Design withdrawn'),
)
}
>
{t('designer.archive', 'Withdraw')}
</Button>
)}
{selected && !isPublished && canEdit && (
<Tooltip label={t('designer.delete', 'Delete draft')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('designer.delete', 'Delete draft')}
onClick={() =>
run(
() => deleteTemplate(selected.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
{/* Placeholders */}
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.variables', 'Placeholders')}
</Text>
<Text size="xs" c="dimmed">
{t('designer.variablesHint', 'Click to insert at the cursor.')}
</Text>
<ScrollArea.Autosize mah={480} type="hover">
<Stack gap={4}>
{variables.map((variable) => (
<Tooltip key={variable.key} label={variable.label} position="left">
<Button
size="compact-xs"
variant="default"
justify="flex-start"
disabled={!canEdit || isPublished}
onClick={() => insertVariable(variable.key)}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button>
</Tooltip>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Group>
)}
<Modal
opened={newOpen}
onClose={() => setNewOpen(false)}
title={t('designer.newVersion', 'New version')}
>
<Stack>
<TextInput
label={t('designer.name', 'Version name')}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
withAsterisk
/>
<Text size="xs" c="dimmed">
{t(
'designer.newHint',
'Starts from the live design, or the built-in layout if this type has none.',
)}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
loading={creating}
disabled={!newName.trim()}
onClick={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
>
{t('designer.create', 'Create')}
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default CertificateDesignerPage;

View File

@@ -0,0 +1,183 @@
import { useMemo } from 'react';
import {
Badge,
Group,
Paper,
ScrollArea,
Text,
Timeline,
Tooltip,
} from '@mantine/core';
import {
IconFileUpload,
IconMessage,
IconArrowRight,
IconUserCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
type ApplicationDetail,
} from '@ema-platform/api';
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
interface ActivityEntry {
id: string;
kind: EntryKind;
at: string;
actor: string;
title: string;
detail?: string;
color?: string;
}
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
status: IconArrowRight,
remark: IconMessage,
upload: IconFileUpload,
assignment: IconUserCheck,
};
/**
* Chronological record of everything that has happened to an application.
*
* Merged client-side from the three collections the detail endpoint already
* returns — status transitions, officer remarks and document uploads. There is
* no single activity-feed endpoint, so this is assembled rather than fetched;
* 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 }) {
const { t, i18n } = useTranslation();
const entries = useMemo<ActivityEntry[]>(() => {
const merged: ActivityEntry[] = [];
for (const history of detail.history ?? []) {
// A transition that does not move the status is a workflow control
// (assignment, escalation), not a decision — labelled as such so the
// trail does not read as "Under Review → Under Review".
const isAssignment = history.fromStatus === history.toStatus;
merged.push({
id: `status-${history.id}`,
kind: isAssignment ? 'assignment' : 'status',
at: history.createdAt,
actor: history.actorName ?? t('review.activity.system', 'System'),
title: isAssignment
? t(`review.events.${history.event}`, {
defaultValue: history.event,
})
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'}${
STATUS_LABELS[history.toStatus]
}`,
detail: history.remark ?? undefined,
color: STATUS_COLORS[history.toStatus],
});
}
for (const remark of detail.remarks ?? []) {
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,
defaultValue: 'Correction requested on {{target}}',
}),
detail: remark.remark,
color: remark.resolvedAt ? 'teal' : 'orange',
});
}
for (const attachment of detail.attachments ?? []) {
const file = attachment.files?.[0];
if (!file) continue;
merged.push({
id: `upload-${attachment.id}`,
kind: 'upload',
at: attachment.createdAt ?? detail.application.createdAt,
actor: t('review.activity.applicant', 'Applicant'),
title: t('review.activity.uploaded', {
document: attachment.documentKey,
defaultValue: 'Uploaded {{document}}',
}),
detail: file.originalName,
color: 'blue',
});
}
// Newest first: an officer opening a review wants the latest state, not
// the application's origin story.
return merged.sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
);
}, [detail, t]);
if (entries.length === 0) {
return (
<Paper withBorder p="md">
<Text size="sm" c="dimmed">
{t('review.activity.empty', 'No activity recorded yet.')}
</Text>
</Paper>
);
}
return (
<Paper withBorder p="md" h="100%">
<Group justify="space-between" mb="sm">
<Text fw={600} size="sm">
{t('review.activity.title', 'Activity & audit trail')}
</Text>
<Badge variant="light" size="sm">
{entries.length}
</Badge>
</Group>
<ScrollArea.Autosize mah={520} type="hover" offsetScrollbars>
<Timeline bulletSize={20} lineWidth={2}>
{entries.map((entry) => {
const EntryIcon = ICONS[entry.kind];
return (
<Timeline.Item
key={entry.id}
bullet={<EntryIcon size={12} />}
color={entry.color}
title={
<Text size="xs" fw={600}>
{entry.title}
</Text>
}
>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{entry.actor}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Tooltip
label={new Date(entry.at).toLocaleString(i18n.language)}
withArrow
>
<Text size="xs" c="dimmed">
{new Date(entry.at).toLocaleDateString(i18n.language)}
</Text>
</Tooltip>
</Group>
{entry.detail && (
<Text size="xs" mt={2}>
{entry.detail}
</Text>
)}
</Timeline.Item>
);
})}
</Timeline>
</ScrollArea.Autosize>
</Paper>
);
}

View File

@@ -0,0 +1,243 @@
import {
ActionIcon,
Avatar,
Badge,
Button,
Divider,
Group,
Menu,
Paper,
Text,
Tooltip,
rem,
} from '@mantine/core';
import { IconDots, IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { STATUS_COLORS, STATUS_LABELS, type LicenseStatus } from '@ema-platform/api';
import type { ActionId, ResolvedAction } from '../config/actions';
import type { SlaState } from '../sla';
interface DecisionBarProps {
status: LicenseStatus;
assigneeName?: string | null;
sla?: SlaState;
actions: ResolvedAction[];
busyAction?: ActionId | null;
onAction: (action: ResolvedAction) => void;
}
/**
* The single place decisions are taken.
*
* Sticky to the bottom of the viewport and full workspace width, so it is
* reachable at any scroll depth — the actions used to sit in a right-hand
* column that scrolled away, meaning an officer reading the last document had
* to scroll back up to act on it.
*
* Layout follows the action tiers: workflow controls left, the decision right,
* everything else in the overflow menu. At most one filled button, so where to
* look is never ambiguous.
*/
export function DecisionBar({
status,
assigneeName,
sla,
actions,
busyAction,
onAction,
}: DecisionBarProps) {
const { t } = useTranslation();
const workflow = actions.filter((a) => a.tier === 'workflow');
// Three is the cap: past that the bar stops reading as a decision and starts
// reading as a toolbar. The rest stay reachable in the overflow menu.
const primary = actions.filter((a) => a.tier === 'primary').slice(0, 3);
const overflowPrimary = actions.filter((a) => a.tier === 'primary').slice(3);
const secondary = [
...overflowPrimary,
...actions.filter((a) => a.tier === 'secondary'),
];
const destructive = actions.filter((a) => a.tier === 'destructive');
return (
<Paper
withBorder
shadow="md"
px="lg"
py="sm"
style={{
position: 'sticky',
bottom: 0,
zIndex: 60,
borderRadius: 0,
marginInline: `calc(-1 * var(--mantine-spacing-lg))`,
background: 'var(--mantine-color-body)',
}}
role="region"
aria-label={t('review.decisionBar', 'Decision bar')}
>
<Group justify="space-between" wrap="nowrap" gap="md">
{/* Left: where the application stands, and who has it. */}
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{STATUS_LABELS[status]}
</Badge>
{assigneeName && (
<Group gap={6} wrap="nowrap">
<Avatar size={24} radius="xl" color="blue">
{assigneeName.slice(0, 2).toUpperCase()}
</Avatar>
<Text size="sm" c="dimmed" truncate>
{assigneeName}
</Text>
</Group>
)}
{sla && sla.state !== 'untracked' && (
<Tooltip label={sla.tooltip} withArrow>
<Badge
color={sla.color}
variant="light"
// Never colour alone: the label carries the same meaning for
// anyone who cannot distinguish the hues.
leftSection={
sla.state === 'breached' ? <IconAlertTriangle size={12} /> : undefined
}
>
{sla.label}
</Badge>
</Tooltip>
)}
{workflow.length > 0 && <Divider orientation="vertical" />}
{workflow.map((action) => (
<ActionButton
key={action.id}
action={action}
busy={busyAction === action.id}
onAction={onAction}
size="xs"
/>
))}
</Group>
{/* Right: the decision. */}
<Group gap="xs" wrap="nowrap">
{primary.map((action) => (
<ActionButton
key={action.id}
action={action}
busy={busyAction === action.id}
onAction={onAction}
size="sm"
/>
))}
{(secondary.length > 0 || destructive.length > 0) && (
<Menu position="top-end" withinPortal shadow="md" width={240}>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
aria-label={t('review.moreActions', 'More actions')}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{secondary.map((action) => (
<MenuAction key={action.id} action={action} onAction={onAction} />
))}
{destructive.length > 0 && (
<>
<Menu.Divider />
<Menu.Label>
{t('review.irreversible', 'Cannot be undone')}
</Menu.Label>
{destructive.map((action) => (
<MenuAction
key={action.id}
action={action}
onAction={onAction}
color="red"
/>
))}
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
</Paper>
);
}
interface ActionButtonProps {
action: ResolvedAction;
busy: boolean;
size: 'xs' | 'sm';
onAction: (action: ResolvedAction) => void;
}
/**
* A disabled button always says why.
*
* Mantine strips pointer events from a disabled button, so the Tooltip has to
* wrap a span — otherwise the one case where the explanation matters is the
* one case it never appears.
*/
function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const { t } = useTranslation();
const button = (
<Button
size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
color={action.color}
loading={busy}
disabled={!action.enabled}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
</Button>
);
if (action.enabled) return button;
return (
<Tooltip label={action.disabledReason} withArrow position="top">
<span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
</Tooltip>
);
}
function MenuAction({
action,
onAction,
color,
}: {
action: ResolvedAction;
onAction: (action: ResolvedAction) => void;
color?: string;
}) {
const { t } = useTranslation();
const item = (
<Menu.Item
color={color}
disabled={!action.enabled}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
</Menu.Item>
);
if (action.enabled) return item;
return (
<Tooltip label={action.disabledReason} withArrow position="left">
<div>{item}</div>
</Tooltip>
);
}
export const DECISION_BAR_HEIGHT = rem(60);

View File

@@ -0,0 +1,311 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Group,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { ResolvedAction } from '../config/actions';
/** Reason codes offered per action. Free text is always available too. */
const REASON_CODES: Record<string, string[]> = {
reject: [
'review.reasons.incompleteDocuments',
'review.reasons.belowCapital',
'review.reasons.failedInspection',
'review.reasons.ineligibleApplicant',
'review.reasons.duplicateApplication',
],
'request-adjustment': [
'review.reasons.illegibleDocument',
'review.reasons.expiredDocument',
'review.reasons.missingDocument',
'review.reasons.inconsistentDetails',
],
hold: [
'review.reasons.awaitingThirdParty',
'review.reasons.legalProceedings',
'review.reasons.applicantRequest',
],
escalate: [
'review.reasons.aboveAuthority',
'review.reasons.policyUnclear',
'review.reasons.conflictOfInterest',
],
};
export interface DecisionSubmission {
reasonCode?: string;
reason: string;
/** Chosen officer, for Assign and Escalate. */
officerId?: string;
/** Documents the applicant must fix. Adjustments only. */
deficiencies: string[];
/** The message that will be sent, after any officer edit. */
notificationBody: string;
}
interface DecisionConfirmModalProps {
action: ResolvedAction | null;
applicantName: string;
applicationNumber: string;
/** Document keys the officer flagged, for the deficiency checklist. */
flaggedDocuments?: string[];
/** Populated for Assign and Escalate, which must name a person. */
officers?: Array<{ id: string; name: string | null }>;
submitting?: boolean;
onClose: () => void;
onConfirm: (submission: DecisionSubmission) => void;
}
/**
* One confirmation anatomy for every decision.
*
* Each decision used to have its own ad-hoc modal — some with a reason, some
* without, none showing what the applicant would actually receive. This gives
* all of them the same five parts: the consequence in plain language naming
* the applicant and application, a reason (required where it matters), the
* document deficiency checklist for adjustments, an editable preview of the
* message that will be sent, and an explicit warning where the step cannot be
* undone.
*/
export function DecisionConfirmModal({
action,
applicantName,
applicationNumber,
flaggedDocuments = [],
officers = [],
submitting,
onClose,
onConfirm,
}: DecisionConfirmModalProps) {
const { t } = useTranslation();
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [deficiencies, setDeficiencies] = useState<string[]>([]);
const [notification, setNotification] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [officerId, setOfficerId] = useState<string | null>(null);
const [confirmText, setConfirmText] = useState('');
const codes = action ? (REASON_CODES[action.id] ?? []) : [];
// `flaggedDocuments` is a fresh array on every parent render, so keying the
// reset effect on its identity would wipe the officer's edits continuously.
const flaggedKey = flaggedDocuments.join('|');
// Reset per opening, and seed the message the applicant will receive so the
// officer edits real copy rather than composing from nothing.
useEffect(() => {
if (!action) return;
setReasonCode(null);
setReason('');
setDeficiencies(flaggedDocuments);
setAcknowledged(false);
setOfficerId(null);
setConfirmText('');
setNotification(
t(`review.notifications.${action.id}`, {
applicant: applicantName,
number: applicationNumber,
defaultValue: t('review.notifications.fallback', {
applicant: applicantName,
number: applicationNumber,
action: t(action.labelKey),
defaultValue:
'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
}),
}),
);
}, [action, applicantName, applicationNumber, flaggedKey, t]);
if (!action) return null;
const needsOfficer = action.id === 'assign' || action.id === 'escalate';
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
const blocked =
reasonMissing ||
(needsOfficer && !officerId) ||
(action.irreversible && !acknowledged) ||
// A destructive action must have the consequence typed out, not just
// acknowledged with a tick — it is the last stop before something
// irreversible happens to a real operator.
(action.tier === 'destructive' && confirmText.trim() !== applicationNumber);
return (
<Modal
opened
onClose={onClose}
title={t(action.labelKey)}
size="lg"
// Focus returns to the trigger on close, and Esc dismisses.
trapFocus
returnFocus
closeOnEscape
>
<Stack gap="md">
{/* 1. What is about to happen, in plain language. */}
<Text size="sm">
{t(`review.consequences.${action.id}`, {
applicant: applicantName,
number: applicationNumber,
defaultValue: t('review.consequences.fallback', {
applicant: applicantName,
number: applicationNumber,
defaultValue:
'This updates application {{number}} for {{applicant}}.',
}),
})}
</Text>
{/* 1b. Who picks it up. */}
{needsOfficer && (
<Select
label={
action.id === 'escalate'
? t('review.supervisor', 'Supervisor')
: t('review.officer', 'Officer')
}
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
data={officers.map((officer) => ({
value: officer.id,
label: officer.name ?? officer.id,
}))}
value={officerId}
onChange={setOfficerId}
searchable
withAsterisk
nothingFoundMessage={t('review.noOfficers', 'No officers found')}
/>
)}
{/* 2. Reason — coded plus free text. */}
{action.requiresReason && (
<>
{codes.length > 0 && (
<Select
label={t('review.reasonCode', 'Reason')}
placeholder={t('review.reasonCodePlaceholder', 'Select a reason')}
data={codes.map((code) => ({ value: code, label: t(code) }))}
value={reasonCode}
onChange={setReasonCode}
clearable
withAsterisk
/>
)}
<Textarea
label={t('review.reasonDetail', 'Details for the applicant')}
description={t(
'review.reasonDetailHint',
'This text is sent to the applicant verbatim.',
)}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
autosize
minRows={3}
withAsterisk={!reasonCode}
/>
</>
)}
{/* 3. Deficiency checklist — the applicant sees exactly this list. */}
{action.id === 'request-adjustment' && flaggedDocuments.length > 0 && (
<Checkbox.Group
label={t('review.deficiencies', 'Items the applicant must correct')}
description={t(
'review.deficienciesHint',
'Only the ticked items become editable for the applicant.',
)}
value={deficiencies}
onChange={setDeficiencies}
>
<Stack gap={4} mt="xs">
{flaggedDocuments.map((key) => (
<Checkbox key={key} value={key} label={key} />
))}
</Stack>
</Checkbox.Group>
)}
{/* 4. Editable preview of the outbound message. */}
<Textarea
label={t('review.notificationPreview', 'Message to the applicant')}
description={t(
'review.notificationPreviewHint',
'Sent by SMS and email. Edit before confirming if needed.',
)}
value={notification}
onChange={(event) => setNotification(event.currentTarget.value)}
autosize
minRows={3}
/>
{/* 5. Irreversibility, acknowledged explicitly. */}
{action.irreversible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
<Stack gap="xs">
<Text size="sm">
{t(
'review.irreversibleWarning',
'This decision is final and cannot be undone from the backoffice.',
)}
</Text>
<Checkbox
checked={acknowledged}
onChange={(event) => setAcknowledged(event.currentTarget.checked)}
label={t('review.irreversibleAck', 'I understand this is final')}
/>
</Stack>
</Alert>
)}
{/* 5b. Destructive actions require the application number typed out. */}
{action.tier === 'destructive' && (
<TextInput
label={t('review.typeToConfirm', {
number: applicationNumber,
defaultValue: 'Type {{number}} to confirm',
})}
value={confirmText}
onChange={(event) => setConfirmText(event.currentTarget.value)}
placeholder={applicationNumber}
error={
confirmText && confirmText.trim() !== applicationNumber
? t('review.confirmMismatch', 'Does not match')
: undefined
}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
color={action.color}
loading={submitting}
disabled={blocked}
onClick={() =>
onConfirm({
reasonCode: reasonCode ?? undefined,
officerId: officerId ?? undefined,
reason: reason.trim() || (reasonCode ? t(reasonCode) : ''),
deficiencies,
notificationBody: notification,
})
}
>
{t(action.labelKey)}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,429 @@
import { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Checkbox,
Drawer,
Group,
Paper,
Progress,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconCheck,
IconDownload,
IconEye,
IconFileText,
IconRotate,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { notifications } from '@mantine/notifications';
interface DocumentsTabProps {
applicationId: string;
attachments: Attachment[];
/** From the licence type config, so completeness is measured against rules. */
requirements: DocumentRequirement[];
/** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
onFlagRemark: (documentKey: string, remark: string) => void;
}
/**
* The reviewer's document workspace.
*
* Previously a list with View and Download buttons that had no handlers at
* all — the officer could see that a document existed but not what was in it,
* which makes "approve documents" an act of faith. This previews inline,
* measures what is uploaded against what the licence type requires, and lets
* each document be flagged with its own reason.
*/
export function DocumentsTab({
applicationId,
attachments,
requirements,
flags,
onToggleFlag,
onFlagRemark,
}: DocumentsTabProps) {
const { t } = useTranslation();
const [preview, setPreview] = useState<Attachment | null>(null);
const [rejecting, setRejecting] = useState<Record<string, string>>({});
// Verdicts are persisted per document, so an accept survives a reload and
// is visible to whoever picks the application up next.
const { data: reviews = [] } = useGetDocumentReviewsQuery(applicationId, {
skip: !applicationId,
});
const [reviewDocument, { isLoading: saving }] = useReviewDocumentMutation();
const [clearReview] = useClearDocumentReviewMutation();
const verdictFor = (documentKey: string) =>
reviews.find((review) => review.documentKey === documentKey);
async function decide(
documentKey: string,
decision: 'ACCEPTED' | 'REJECTED',
attachmentId?: string,
) {
const reason = rejecting[documentKey]?.trim();
if (decision === 'REJECTED' && !reason) {
// The applicant is shown this verbatim, so refuse to send an empty one.
notifications.show({
color: 'red',
title: t('review.documents.reasonRequired', 'A reason is required'),
message: '',
});
return;
}
try {
await reviewDocument({
id: applicationId,
documentKey,
decision,
reason: decision === 'REJECTED' ? reason : undefined,
attachmentId,
}).unwrap();
setRejecting((prev) => {
const next = { ...prev };
delete next[documentKey];
return next;
});
} catch {
notifications.show({
color: 'red',
title: t('review.documents.saveFailed', 'Could not save the verdict'),
message: '',
});
}
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
: 100;
const previewFile = preview?.files?.[0];
const isImage = previewFile?.mimeType?.startsWith('image/');
const isPdf = previewFile?.mimeType === 'application/pdf';
return (
<Stack gap="md">
{/* Completeness against the licence type's own requirement list. */}
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => r.name.en ?? r.key).join(', ')}
</Text>
</Alert>
)}
</Paper>
{attachments.map((attachment) => {
const file = attachment.files?.[0];
const flagged = attachment.documentKey in flags;
const verdict = verdictFor(attachment.documentKey);
const pendingReject = attachment.documentKey in rejecting;
return (
<Paper withBorder p="md" key={attachment.id}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IconFileText size={20} stroke={1.6} />
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>
{attachment.documentKey}
</Text>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
</Text>
</div>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
</Badge>
)}
</Group>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<Button
size="compact-sm"
variant="light"
leftSection={<IconEye size={14} />}
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<ActionIcon
variant="subtle"
disabled={!file?.url}
component="a"
href={file?.url}
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
>
<IconDownload size={16} />
</ActionIcon>
</span>
</Tooltip>
{/* Accept / Reject are the officer's own record of having
checked the file, persisted independently of any
adjustment round. */}
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
}
>
<IconCheck size={16} />
</ActionIcon>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
}))
}
>
<IconX size={16} />
</ActionIcon>
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
onClick={() =>
clearReview({
id: applicationId,
documentKey: attachment.documentKey,
})
}
>
<IconRotate size={16} />
</ActionIcon>
</Tooltip>
)}
<Checkbox
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
/>
</Group>
</Group>
{pendingReject && (
<Group mt="sm" gap="xs" align="flex-start" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
)}
value={rejecting[attachment.documentKey]}
onChange={(e) =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: e.currentTarget.value,
}))
}
/>
<Button
size="compact-sm"
color="red"
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
</Button>
</Group>
)}
{flagged && (
<TextInput
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
}
/>
)}
</Paper>
);
})}
<Drawer
opened={Boolean(preview)}
onClose={() => setPreview(null)}
position="right"
size="xl"
title={preview?.documentKey}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
trapFocus
returnFocus
>
{previewFile?.url ? (
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? 'document'}
style={{ width: '100%', height: '80vh', border: 'none' }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? 'document'}
style={{ maxWidth: '100%' }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
)}
</Text>
<Button
component="a"
href={previewFile.url}
target="_blank"
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
</Button>
</Stack>
)
) : null}
</Drawer>
</Stack>
);
}

View File

@@ -0,0 +1,312 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { PERMISSIONS } from '../../../layouts/nav-config';
/**
* Where an action is rendered. One tier per action, decided here rather than
* by whoever happens to be laying out the page — that is what produced buttons
* scattered down the right-hand column with no ordering principle.
*/
export type ActionTier =
/** Approve / Request Adjustment / Reject. Decision Bar, right. Max three. */
| 'primary'
/** Claim, Assign, Escalate, Hold, Return. Decision Bar, left. */
| 'workflow'
/** Print, Export, Certificate, Audit, Copy Link. Overflow menu. */
| 'secondary'
/** Void, Revoke, Cancel. Overflow menu, separated, red, typed confirm. */
| 'destructive';
export type ActionId =
| 'claim'
| 'assign'
| 'escalate'
| 'hold'
| 'resume'
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
| 'reject'
| 'confirm-payment'
| 'print'
| 'copy-link'
| 'download-documents'
| 'generate-certificate'
| 'audit-trail';
export interface ActionDefinition {
id: ActionId;
tier: ActionTier;
labelKey: string;
/** Statuses the action can be fired from. Mirrors the API transition table. */
from?: LicenseStatus[];
/** Any one of these authorises it. Omitted means no permission needed. */
permissions?: string[];
/** Only one primary action is ever filled; everything else is light. */
emphasis?: 'filled' | 'light' | 'subtle';
color?: string;
/** Requires a typed reason before it will submit. */
requiresReason?: boolean;
/** Cannot be undone — the confirmation says so explicitly. */
irreversible?: boolean;
}
/**
* Every action an officer can take, in one place.
*
* Actions absent from this list are absent because the API has no endpoint for
* them. Void, Revoke and Cancel are the notable gaps: `SUSPEND_LICENSE` and
* `CANCEL_LICENSE` permissions exist, but no route does either, so rendering
* them would be a button that cannot work.
*/
export const ACTIONS: ActionDefinition[] = [
// ------------------------------------------------------------- workflow
{
id: 'claim',
tier: 'workflow',
labelKey: 'review.actions.claim',
from: ['SUBMITTED'],
permissions: ['can:claim:license-application'],
emphasis: 'light',
},
{
id: 'assign',
tier: 'workflow',
labelKey: 'review.actions.assign',
from: [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'ON_HOLD',
],
permissions: ['can:assign:license-application'],
emphasis: 'subtle',
},
{
id: 'escalate',
tier: 'workflow',
labelKey: 'review.actions.escalate',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
permissions: ['can:escalate:license-application'],
emphasis: 'subtle',
requiresReason: true,
},
{
id: 'hold',
tier: 'workflow',
labelKey: 'review.actions.hold',
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
],
permissions: ['can:hold:license-application'],
emphasis: 'subtle',
requiresReason: true,
},
{
id: 'resume',
tier: 'workflow',
labelKey: 'review.actions.resume',
from: ['ON_HOLD'],
permissions: ['can:hold:license-application'],
emphasis: 'light',
},
// -------------------------------------------------------------- primary
{
id: 'complete-review',
tier: 'primary',
labelKey: 'review.actions.completeReview',
from: ['UNDER_REVIEW'],
permissions: [
'can:review:license-application',
'can:evaluate:license-application',
],
emphasis: 'filled',
},
{
id: 'approve-documents',
tier: 'primary',
labelKey: 'review.actions.approveDocuments',
from: ['UNDER_EVALUATION'],
permissions: ['can:evaluate:license-application'],
emphasis: 'filled',
},
{
id: 'schedule-inspection',
tier: 'primary',
labelKey: 'review.actions.scheduleInspection',
from: ['INSPECTION_PENDING'],
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
{
id: 'record-inspection',
tier: 'primary',
labelKey: 'review.actions.recordInspection',
from: ['INSPECTION_PENDING'],
permissions: ['can:update:inspection'],
emphasis: 'filled',
},
{
id: 'final-approve',
tier: 'primary',
labelKey: 'review.actions.finalApprove',
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
permissions: ['can:approve:license-application'],
emphasis: 'filled',
color: 'teal',
irreversible: true,
},
{
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
permissions: ['can:request-adjustment:license-application'],
emphasis: 'light',
color: 'orange',
requiresReason: true,
},
{
id: 'reject',
tier: 'primary',
labelKey: 'review.actions.reject',
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
],
permissions: ['can:reject:license-application'],
emphasis: 'light',
color: 'red',
requiresReason: true,
irreversible: true,
},
{
id: 'confirm-payment',
tier: 'primary',
labelKey: 'review.actions.confirmPayment',
from: ['PAID'],
permissions: ['can:confirm:license-payment'],
emphasis: 'filled',
color: 'teal',
},
// ------------------------------------------------------------ secondary
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
{ id: 'copy-link', tier: 'secondary', labelKey: 'review.actions.copyLink' },
{
id: 'download-documents',
tier: 'secondary',
labelKey: 'review.actions.downloadDocuments',
},
{
id: 'generate-certificate',
tier: 'secondary',
labelKey: 'review.actions.generateCertificate',
from: ['CERTIFICATE_ISSUED'],
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
},
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
];
export interface ResolvedAction extends ActionDefinition {
/** False when the officer can see it but cannot fire it right now. */
enabled: boolean;
/**
* Why it is disabled, already translated. Never null when `enabled` is
* false — a greyed-out control with no explanation is the thing this whole
* model exists to prevent.
*/
disabledReason?: string;
}
export interface ResolveContext {
detail: ApplicationDetail;
currentUserId: string;
can: (permissions?: string[]) => boolean;
/** Translated strings for the disabled explanations. */
reasons: {
wrongStatus: string;
notAssigned: string;
noPermission: string;
needsFlags: string;
needsCapital: string;
needsInspection: string;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
}
/**
* Which actions to render, and for each, whether it can fire and why not.
*
* Actions the user has no permission for are dropped entirely; actions that
* are merely unavailable right now are kept and disabled with a reason, so the
* officer can see what the next step would be rather than wondering whether
* the screen is broken.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
// Status-scoped actions vanish outside their stage rather than piling up
// as a column of permanently dead buttons.
if (action.from && !action.from.includes(app.status)) return [];
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
const disabled = (reason: string): ResolvedAction => ({
...action,
enabled: false,
disabledReason: reason,
});
// Decisions belong to whoever holds the application.
const needsOwnership =
action.tier === 'primary' && action.id !== 'confirm-payment';
if (
needsOwnership &&
app.assignedOfficerId &&
app.assignedOfficerId !== currentUserId
) {
return disabled(reasons.notAssigned);
}
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
return disabled(reasons.needsFlags);
}
if (action.id === 'final-approve') {
const threshold = app.licenseType?.capitalThreshold;
const needsCapital = threshold != null && Number(threshold) > 0;
if (needsCapital && app.capitalAmountVerified == null) {
return disabled(reasons.needsCapital);
}
if (
app.licenseType?.inspectionRequired &&
app.status !== 'INSPECTION_COMPLETED'
) {
return disabled(reasons.needsInspection);
}
}
return { ...action, enabled: true };
},
);
}

View File

@@ -0,0 +1,177 @@
import {
IconAnchor,
IconFileDescription,
IconShip,
IconTruck,
IconUsers,
type Icon,
} from '@tabler/icons-react';
import type { LicenseApplication, LicenseType } from '@ema-platform/api';
/**
* Presentation-only metadata per licence type.
*
* Deliberately thin. Everything that actually varies between licence types —
* fees, capital threshold, validity, whether an inspection is required,
* whether a certificate is issued, the form schema, the document and staff
* requirements — already lives in the `license_types` table and arrives from
* `GET /license-types`. The API entity states the rule outright: onboarding a
* new type is meant to be a seed or admin change, not a code change
* (BR-MTO-020).
*
* Duplicating any of that here would fork the source of truth and mean a new
* licence type silently rendered with another type's rules. So this file holds
* only what the database has no opinion about: which icon to draw, and the
* order review tabs appear in.
*/
export interface LicenseTypePresentation {
/** Matches `LicenseType.key`. */
key: string;
icon: Icon;
/** Review tabs, in order. Tabs with no data do not render. */
detailSections: DetailSection[];
}
export type DetailSection =
| 'overview'
| 'company'
| 'financials'
| 'documents'
| 'staff'
| 'inspection';
const DEFAULT_SECTIONS: DetailSection[] = [
'overview',
'company',
'financials',
'documents',
'staff',
'inspection',
];
const PRESENTATION: Record<string, LicenseTypePresentation> = {
FREIGHT_FORWARDER: {
key: 'FREIGHT_FORWARDER',
icon: IconTruck,
detailSections: DEFAULT_SECTIONS,
},
SHIPPING_AGENT: {
key: 'SHIPPING_AGENT',
icon: IconShip,
detailSections: DEFAULT_SECTIONS,
},
COMBINED_SA_FF: {
key: 'COMBINED_SA_FF',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
},
JOINT_INVESTOR: {
key: 'JOINT_INVESTOR',
icon: IconUsers,
// Terminates at COMPLETED with no payment and no certificate, and the
// workflow skips inspection for it.
detailSections: ['overview', 'company', 'financials', 'documents', 'staff'],
},
MULTIMODAL_TRANSPORT_OPERATOR: {
key: 'MULTIMODAL_TRANSPORT_OPERATOR',
icon: IconAnchor,
detailSections: DEFAULT_SECTIONS,
},
};
/** Falls back to a generic presentation so an unseeded type still renders. */
export function presentationFor(key: string | undefined): LicenseTypePresentation {
return (
(key && PRESENTATION[key]) || {
key: key ?? 'UNKNOWN',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
}
);
}
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);
// ---------------------------------------------------------------- eligibility
export interface EligibilityRule {
id: string;
/** Plain-language statement of the rule, already interpolated. */
label: string;
/** What the application actually declares/verifies, formatted. */
actual: string;
status: 'pass' | 'fail' | 'unknown';
}
/**
* Turns the licence type's configured thresholds into a checked list.
*
* The capital threshold used to be applied invisibly — the officer saw a
* disabled approve button and had to know why. Rendering it as an explicit
* pass/fail line means the rule, the figure it was checked against, and the
* outcome are all on screen.
*/
export function evaluateEligibility(
application: LicenseApplication,
licenseType: LicenseType | undefined,
locale: string,
): EligibilityRule[] {
const rules: EligibilityRule[] = [];
const threshold =
licenseType?.capitalThreshold == null
? undefined
: Number(licenseType.capitalThreshold);
if (threshold !== undefined && !Number.isNaN(threshold)) {
const verified =
application.capitalAmountVerified == null
? undefined
: Number(application.capitalAmountVerified);
const declared =
application.capitalAmountDeclared == null
? undefined
: Number(application.capitalAmountDeclared);
const effective = verified ?? declared;
const currency = licenseType?.feeCurrency ?? 'ETB';
const format = (value: number) =>
`${value.toLocaleString(locale)} ${currency}`;
rules.push({
id: 'capital-threshold',
label: `Paid-up capital ≥ ${format(threshold)}`,
actual:
effective === undefined
? 'Not recorded'
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
// An unverified declaration is not evidence, so it reads as unknown
// rather than as a pass the officer never actually made.
status:
effective === undefined || verified === undefined
? 'unknown'
: effective >= threshold
? 'pass'
: 'fail',
});
}
if (licenseType?.inspectionRequired) {
const inspected = [
'INSPECTION_COMPLETED',
'APPROVED',
'PAYMENT_PENDING',
'PAID',
'PAYMENT_CONFIRMED',
'CERTIFICATE_ISSUED',
'COMPLETED',
].includes(application.status);
rules.push({
id: 'inspection',
label: 'Physical inspection completed',
actual: inspected ? 'Recorded' : 'Not yet recorded',
status: inspected ? 'pass' : 'unknown',
});
}
return rules;
}

View File

@@ -0,0 +1,72 @@
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
import { computeSla } from './sla';
/**
* Escapes one CSV field.
*
* Company names routinely contain commas, and remarks contain quotes and
* newlines — unescaped, either one shifts every later column on the row.
*/
function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const text = String(value);
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
const COLUMNS: Array<{
header: string;
value: (app: LicenseApplication, locale: string) => unknown;
}> = [
{ header: 'Application #', value: (a) => a.applicationNumber },
{ header: 'Company', value: (a) => a.companyName },
{ header: 'Trade name', value: (a) => a.tradeName },
{ header: 'TIN', value: (a) => a.tinNumber },
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
{ header: 'Kind', value: (a) => a.kind },
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
{
header: 'Submitted',
value: (a, locale) =>
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
},
{
header: 'Decided',
value: (a, locale) =>
a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '',
},
{ header: 'SLA', value: (a) => computeSla(a).label },
{ header: 'Adjustment rounds', value: (a) => a.adjustmentRound },
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
];
/**
* Exports exactly the rows passed in.
*
* Takes the already-filtered, already-paged list rather than re-querying, so
* what lands in the file is what the officer was looking at. Note this means
* an export covers the current page — exporting a whole filtered result set
* would need a server-side export endpoint, which does not exist.
*/
export function exportApplicationsCsv(
applications: LicenseApplication[],
locale: string,
filename = `licence-applications-${new Date().toISOString().slice(0, 10)}.csv`,
): void {
const header = COLUMNS.map((column) => csvCell(column.header)).join(',');
const rows = applications.map((app) =>
COLUMNS.map((column) => csvCell(column.value(app, locale))).join(','),
);
// BOM so Excel opens Amharic and other non-ASCII content as UTF-8.
const blob = new Blob(['', [header, ...rows].join('\r\n')], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -1,172 +1,722 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Checkbox,
Container,
Group,
Loader,
MultiSelect,
Pagination,
Paper,
SegmentedControl,
Select,
Skeleton,
Stack,
Kbd,
Modal,
Table,
Tabs,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
IconAlertCircle,
IconDownload,
IconRefresh,
IconSearch,
IconSortAscending,
IconSortDescending,
IconX,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useClaimApplicationMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
useGetLicenseTypesQuery,
useGetQueueCountsQuery,
useGetQueueQuery,
useLazyExportApplicationsQuery,
type LicenseApplication,
type LicenseStatus,
type QueueFilter,
} from '@ema-platform/api';
import { EmptyState, ErrorState } from '@ema-platform/ui';
import { computeSla } from '../sla';
import {
DEFAULT_VIEW,
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
searchParamsFromFilter,
writeLastView,
type SavedViewId,
} from '../queue-views';
import { exportApplicationsCsv } from '../export';
import { setDensity } from '../../../store/preferences.slice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
const PAGE_SIZE = 25;
const SEARCH_DEBOUNCE_MS = 300;
const ALL_STATUSES: LicenseStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'RESUBMIT_REQUIRED',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'ON_HOLD',
'APPROVED',
'PAYMENT_PENDING',
'PAID',
'PAYMENT_CONFIRMED',
'CERTIFICATE_ISSUED',
'COMPLETED',
'REJECTED',
];
/**
* The officer work pool.
*
* "Unclaimed" is the shared queue; claiming moves an application into "Mine"
* and it stays there through every adjustment round.
* Saved views across the top, facets serialised into the URL so a filtered
* queue can be shared, and server-side pagination — the previous version
* rendered `data.items` unpaged, which was fine at demo volumes and would have
* stopped being fine somewhere in the hundreds.
*/
export function LicenseQueuePage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [tab, setTab] = useState<'unclaimed' | 'mine'>('unclaimed');
const [search, setSearch] = useState('');
const { typeCode } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const dispatch = useAppDispatch();
const density = useAppSelector((state) => state.preferences.density);
const [view, setView] = useState<SavedViewId>(
() => (searchParams.get('view') as SavedViewId) || readLastView(),
);
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [selected, setSelected] = useState<string[]>([]);
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
const [cursor, setCursor] = useState(0);
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
[searchParams],
);
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
const { data: licenseTypes } = useGetLicenseTypesQuery();
const { data: counts } = useGetQueueCountsQuery();
// A `/licence-review/type/:typeCode` deep link pins the type facet.
const pinnedTypeId = useMemo(() => {
if (!typeCode) return undefined;
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
}, [typeCode, licenseTypes]);
const filter: QueueFilter = useMemo(
() => ({
...activeView.filter,
...urlFilter,
search: debouncedSearch || undefined,
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
}),
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
);
// One query per source; the two inactive ones are skipped, so switching
// views costs a single request rather than keeping three in flight.
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
const active =
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
const queue = useGetQueueQuery({ search: search || undefined });
const mine = useGetAssignedToMeQuery({ search: search || undefined });
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
/**
* Exports every row the filter matches, not just the page on screen.
* The server caps the result set and reports when it did, so a truncated
* export says so instead of quietly being wrong.
*/
async function handleExport() {
try {
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
exportApplicationsCsv(result.items, i18n.language);
if (result.truncated) {
notifications.show({
color: 'yellow',
title: t('queue.exportTruncated', 'Export truncated'),
message: t('queue.exportTruncatedBody', {
exported: result.items.length,
total: result.total,
defaultValue:
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
}),
});
}
} catch (err) {
notifications.show({
color: 'red',
title: t('queue.exportFailed', 'Export failed'),
message: extractErrorMessage(err),
});
}
}
const active = tab === 'unclaimed' ? queue : mine;
const items = active.data?.items ?? [];
const total = active.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const updateUrl = useCallback(
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
setSearchParams(
searchParamsFromFilter({ ...urlFilter, ...next }, nextView, nextPage),
{ replace: true },
);
},
[urlFilter, setSearchParams],
);
const changeView = (next: SavedViewId) => {
setView(next);
writeLastView(next);
setPage(1);
setSelected([]);
updateUrl({}, next, 1);
};
const setFacet = (next: Partial<QueueFilter>) => {
setPage(1);
updateUrl(next, view, 1);
};
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
const dir =
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
setFacet({ sortBy: field, sortDir: dir });
};
async function handleClaim(id: string) {
try {
await claim(id).unwrap();
notifications.show({
color: 'teal',
title: 'Claimed',
message: 'The application is now assigned to you.',
title: t('queue.claimed', 'Claimed'),
message: t('queue.claimedBody', 'The application is now assigned to you.'),
});
setTab('mine');
changeView('mine');
} catch (err) {
// A 409 means another officer got there first — refresh so the queue
// stops showing work that is no longer available.
notifications.show({
color: 'red',
title: 'Could not claim',
message: extractErrorMessage(err, 'Another officer already claimed it.'),
title: t('queue.claimFailed', 'Could not claim'),
message: extractErrorMessage(
err,
t('queue.claimRace', 'Another officer already claimed it.'),
),
});
queue.refetch();
active.refetch();
}
}
async function handleBulkClaim() {
const results = await Promise.allSettled(
selected.map((id) => claim(id).unwrap()),
);
const claimed = results.filter((r) => r.status === 'fulfilled').length;
const lost = results.length - claimed;
notifications.show({
color: lost ? 'yellow' : 'teal',
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
// Partial success is the normal case in a shared queue, so it is
// reported rather than swallowed or treated as total failure.
message: lost
? t('queue.bulkClaimPartial', {
count: lost,
defaultValue: '{{count}} were already taken by another officer.',
})
: '',
});
setSelected([]);
active.refetch();
}
const cursorRow = items[cursor];
useQueueKeyboard({
enabled: !helpOpen,
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
// rather than an error the officer has to read.
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
onHelp: () => setHelpOpen(true),
});
const allSelected = items.length > 0 && selected.length === items.length;
const sortIcon =
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
const hasFacets = Boolean(
urlFilter.status?.length ||
urlFilter.licenseTypeId ||
urlFilter.assignee ||
urlFilter.submittedFrom ||
debouncedSearch,
);
return (
<Container size="xl" py="md">
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
<Group justify="space-between" mb="md">
<Title order={3}>Licence applications</Title>
<Group>
<TextInput
placeholder="Company, TIN or number"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<div>
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
</Text>
)}
</div>
<Group gap="xs">
<Tooltip label={t('queue.refresh', 'Refresh')}>
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<SegmentedControl
value={tab}
onChange={(v) => setTab(v as 'unclaimed' | 'mine')}
size="xs"
value={density}
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
data={[
{ label: `Unclaimed (${queue.data?.total ?? 0})`, value: 'unclaimed' },
{ label: `Mine (${mine.data?.total ?? 0})`, value: 'mine' },
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
{ label: t('queue.compact', 'Compact'), value: 'compact' },
]}
/>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={handleExport}
loading={exporting}
disabled={total === 0}
>
{t('queue.export', 'Export CSV')}
</Button>
</Group>
</Group>
{/* Saved views, counted. */}
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
<Tabs.List>
{SAVED_VIEWS.map((savedView) => (
<Tabs.Tab
key={savedView.id}
value={savedView.id}
rightSection={
counts?.[savedView.countKey] ? (
<Badge size="xs" variant="light" circle>
{counts[savedView.countKey]}
</Badge>
) : undefined
}
>
{t(savedView.labelKey)}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
{/* Facets — every one of these is reflected in the URL. */}
<Paper withBorder p="sm" mb="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('queue.search', 'Search')}
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
leftSection={<IconSearch size={14} />}
value={searchInput}
onChange={(e) => setSearchInput(e.currentTarget.value)}
w={240}
/>
<MultiSelect
label={t('queue.status', 'Status')}
placeholder={t('queue.anyStatus', 'Any')}
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
value={urlFilter.status ?? []}
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
clearable
w={240}
/>
{!typeCode && (
<Select
label={t('queue.type', 'Licence type')}
placeholder={t('queue.anyType', 'Any')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name.en ?? type.key,
}))}
value={urlFilter.licenseTypeId ?? null}
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
clearable
w={220}
/>
)}
<TextInput
type="date"
label={t('queue.submittedFrom', 'Submitted from')}
value={urlFilter.submittedFrom ?? ''}
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
/>
<TextInput
type="date"
label={t('queue.submittedTo', 'Submitted to')}
value={urlFilter.submittedTo ?? ''}
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
/>
{hasFacets && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={() => {
setSearchInput('');
setSearchParams(new URLSearchParams(), { replace: true });
}}
>
{t('queue.clearFilters', 'Clear')}
</Button>
)}
</Group>
</Paper>
<Card withBorder padding={0}>
{active.isLoading ? (
<Center h={200}>
<Loader />
</Center>
// Skeleton rows match the real table, so the layout does not jump
// when data lands.
<Stack gap={0} p="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={44} mb="xs" radius="sm" />
))}
</Stack>
) : active.isError ? (
<ErrorState
title={t('queue.errorTitle', 'Could not load the queue')}
description={extractErrorMessage(active.error)}
onRetry={() => active.refetch()}
icon={IconAlertCircle}
/>
) : items.length === 0 ? (
<Center h={160}>
<Text c="dimmed" size="sm">
{tab === 'unclaimed'
? 'No applications waiting to be claimed.'
: 'You have no applications in progress.'}
</Text>
</Center>
<EmptyState
title={
hasFacets
? t('queue.emptyFiltered', 'No applications match these filters')
: t('queue.empty', 'Nothing waiting here')
}
description={
hasFacets
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
}
action={
hasFacets
? {
label: t('queue.clearFilters', 'Clear'),
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
}
: undefined
}
/>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>TIN</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app) => (
<Table.Tr key={app.id}>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt
? new Date(app.submittedAt).toLocaleDateString()
: '—'}
</Text>
</Table.Td>
<Table.Td align="right">
{tab === 'unclaimed' ? (
<Button
size="xs"
loading={claiming}
onClick={() => handleClaim(app.id)}
>
Claim
</Button>
) : (
<Button
size="xs"
variant="light"
onClick={() => navigate(`/licence-review/${app.id}`)}
>
Review
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label={t('queue.selectAll', 'Select all')}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() =>
setSelected(allSelected ? [] : items.map((a) => a.id))
}
/>
</Table.Th>
<SortableTh
label={t('queue.number', 'App #')}
field="applicationNumber"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.company', 'Company')}
field="companyName"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
<SortableTh
label={t('queue.statusCol', 'Status')}
field="status"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.submitted', 'Submitted')}
field="submittedAt"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app, index) => (
<QueueRow
key={app.id}
app={app}
focused={index === cursor}
selected={selected.includes(app.id)}
claiming={claiming}
locale={i18n.language}
onSelect={(checked) =>
setSelected((prev) =>
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
)
}
onClaim={() => handleClaim(app.id)}
onOpen={() => navigate(`/licence-review/${app.id}`)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="space-between" p="sm">
<Text size="sm" c="dimmed">
{t('queue.showing', {
from: (page - 1) * PAGE_SIZE + 1,
to: Math.min(page * PAGE_SIZE, total),
total,
defaultValue: 'Showing {{from}}{{to}} of {{total}}',
})}
</Text>
<Pagination
value={page}
onChange={(next) => {
setPage(next);
updateUrl({}, view, next);
}}
total={pageCount}
size="sm"
/>
</Group>
</>
)}
</Card>
<Modal
opened={helpOpen}
onClose={() => setHelpOpen(false)}
title={t('shortcuts.title', 'Keyboard shortcuts')}
size="sm"
>
<Stack gap="xs">
{KEYBOARD_SHORTCUTS.map((shortcut) => (
<Group key={shortcut.keys} justify="space-between">
<Text size="sm">{t(shortcut.labelKey)}</Text>
<Kbd>{shortcut.keys}</Kbd>
</Group>
))}
</Stack>
</Modal>
{/* Bulk bar. Floating, with the count stated so the scope of the action
is never ambiguous. */}
{selected.length > 0 && (
<Paper
withBorder
shadow="md"
p="sm"
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('queue.selectedCount', {
count: selected.length,
defaultValue: '{{count}} selected',
})}
</Text>
<Group gap="xs">
<Button variant="subtle" onClick={() => setSelected([])}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() =>
exportApplicationsCsv(
items.filter((a) => selected.includes(a.id)),
i18n.language,
)
}
>
{t('queue.export', 'Export CSV')}
</Button>
<Button loading={claiming} onClick={handleBulkClaim}>
{t('queue.bulkClaim', {
count: selected.length,
defaultValue: 'Claim {{count}}',
})}
</Button>
</Group>
</Group>
</Paper>
)}
</Container>
);
}
function SortableTh({
label,
field,
current,
icon,
onSort,
}: {
label: string;
field: NonNullable<QueueFilter['sortBy']>;
current?: QueueFilter['sortBy'];
icon: React.ReactNode;
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
}) {
return (
<Table.Th>
<Group
gap={4}
wrap="nowrap"
style={{ cursor: 'pointer' }}
onClick={() => onSort(field)}
>
<span>{label}</span>
{current === field && icon}
</Group>
</Table.Th>
);
}
function QueueRow({
app,
selected,
focused,
claiming,
locale,
onSelect,
onClaim,
onOpen,
}: {
app: LicenseApplication;
selected: boolean;
focused: boolean;
claiming: boolean;
locale: string;
onSelect: (checked: boolean) => void;
onClaim: () => void;
onOpen: () => void;
}) {
const { t } = useTranslation();
const sla = computeSla(app);
return (
<Table.Tr
// Keyboard cursor. Marked with a left border rather than a background so
// it stays distinguishable from row selection and from hover.
style={
focused
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
: undefined
}
>
<Table.Td>
<Checkbox
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
checked={selected}
onChange={(e) => onSelect(e.currentTarget.checked)}
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
</Text>
</Table.Td>
<Table.Td>
{/* Colour is never the only signal — the label says the same thing. */}
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td align="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
<Button size="xs" loading={claiming} onClick={onClaim}>
{t('queue.claim', 'Claim')}
</Button>
) : (
<Button size="xs" variant="light" onClick={onOpen}>
{t('queue.review', 'Review')}
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
);
}
export default LicenseQueuePage;

View File

@@ -0,0 +1,152 @@
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
export type SavedViewId =
| 'unassigned'
| 'mine'
| 'awaitingApplicant'
| 'overdue'
| 'readyToIssue'
| 'all';
export interface SavedView {
id: SavedViewId;
labelKey: string;
/** Which count from `/counts` labels the tab. */
countKey: keyof QueueCounts;
/** Filters this view pins. The user's own facets layer on top. */
filter: Partial<QueueFilter>;
/** Which list endpoint backs it. */
source: 'queue' | 'mine' | 'all';
}
const AWAITING_APPLICANT: LicenseStatus[] = ['RESUBMIT_REQUIRED'];
const READY_TO_ISSUE: LicenseStatus[] = ['PAYMENT_CONFIRMED'];
/**
* The officer's saved views.
*
* Replaces a two-option SegmentedControl (Unclaimed / Mine) that could not
* express the questions officers actually ask — what is late, what is waiting
* on the applicant, what is ready to issue. Each is a filter preset over the
* same grid rather than a separate screen.
*/
export const SAVED_VIEWS: SavedView[] = [
{
id: 'unassigned',
labelKey: 'queue.views.unassigned',
countKey: 'unassigned',
filter: {},
source: 'queue',
},
{
id: 'mine',
labelKey: 'queue.views.mine',
countKey: 'mine',
filter: {},
source: 'mine',
},
{
id: 'awaitingApplicant',
labelKey: 'queue.views.awaitingApplicant',
countKey: 'awaitingApplicant',
filter: { status: AWAITING_APPLICANT },
source: 'all',
},
{
id: 'overdue',
labelKey: 'queue.views.overdue',
countKey: 'overdue',
filter: { overdue: true },
source: 'all',
},
{
id: 'readyToIssue',
labelKey: 'queue.views.readyToIssue',
countKey: 'readyToIssue',
filter: { status: READY_TO_ISSUE },
source: 'all',
},
{
id: 'all',
labelKey: 'queue.views.all',
countKey: 'all',
filter: {},
source: 'all',
},
];
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
export function readLastView(): SavedViewId {
try {
const stored = localStorage.getItem(LAST_VIEW_KEY) as SavedViewId | null;
return SAVED_VIEWS.some((v) => v.id === stored) ? (stored as SavedViewId) : DEFAULT_VIEW;
} catch {
return DEFAULT_VIEW;
}
}
export function writeLastView(id: SavedViewId): void {
try {
localStorage.setItem(LAST_VIEW_KEY, id);
} catch {
// Not persisting the last view is cosmetic; never break the page for it.
}
}
// ------------------------------------------------------------ URL round-trip
/**
* Reads the user's facets out of the query string.
*
* Filters live in the URL so a filtered queue is a shareable link — "here are
* the six overdue MTO applications" should be something an officer can paste
* into a message, not a state they describe in prose.
*/
export function filterFromSearchParams(params: URLSearchParams): QueueFilter {
const filter: QueueFilter = {};
const search = params.get('q');
if (search) filter.search = search;
const type = params.get('type');
if (type) filter.licenseTypeId = type;
const status = params.get('status');
if (status) filter.status = status.split(',') as LicenseStatus[];
const assignee = params.get('assignee');
if (assignee) filter.assignee = assignee;
const from = params.get('from');
if (from) filter.submittedFrom = from;
const to = params.get('to');
if (to) filter.submittedTo = to;
const sortBy = params.get('sort');
if (sortBy) filter.sortBy = sortBy as QueueFilter['sortBy'];
const sortDir = params.get('dir');
if (sortDir === 'ASC' || sortDir === 'DESC') filter.sortDir = sortDir;
const page = params.get('page');
if (page) {
const parsed = Number(page);
if (Number.isFinite(parsed) && parsed > 0) filter.skip = undefined;
}
return filter;
}
/** Inverse of {@link filterFromSearchParams}. Omits defaults to keep URLs short. */
export function searchParamsFromFilter(
filter: QueueFilter,
view: SavedViewId,
page: number,
): URLSearchParams {
const params = new URLSearchParams();
if (view !== DEFAULT_VIEW) params.set('view', view);
if (filter.search) params.set('q', filter.search);
if (filter.licenseTypeId) params.set('type', filter.licenseTypeId);
if (filter.status?.length) params.set('status', filter.status.join(','));
if (filter.assignee) params.set('assignee', filter.assignee);
if (filter.submittedFrom) params.set('from', filter.submittedFrom);
if (filter.submittedTo) params.set('to', filter.submittedTo);
if (filter.sortBy) params.set('sort', filter.sortBy);
if (filter.sortDir && filter.sortDir !== 'ASC') params.set('dir', filter.sortDir);
if (page > 1) params.set('page', String(page));
return params;
}

View File

@@ -0,0 +1,89 @@
import type { LicenseApplication } from '@ema-platform/api';
/** Amber once this much of the window has been consumed. */
const WARNING_RATIO = 0.7;
const HOUR_MS = 60 * 60 * 1000;
export interface SlaState {
state: 'ok' | 'warning' | 'breached' | 'untracked' | 'decided';
/** Mantine colour. Always paired with `label` — never colour alone. */
color: string;
/** Short text for the badge, e.g. "2d left" or "Overdue 6h". */
label: string;
/** The full explanation, including the target, for the tooltip. */
tooltip: string;
/** Fraction of the window used, clamped to 0..1. */
ratio: number;
}
function formatDuration(ms: number): string {
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
if (hours < 1) return '<1h';
if (hours < 48) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* How an application is tracking against its licence type's SLA.
*
* Types with no `slaHours` are untracked rather than instantly overdue — the
* authority has not set a target for them, which is not the same as missing
* one. Decided applications stop the clock: an approval that took three weeks
* is history, not an outstanding breach.
*/
export function computeSla(
application: LicenseApplication,
now: number = Date.now(),
): SlaState {
const slaHours = application.licenseType?.slaHours;
const submittedAt = application.submittedAt;
if (!slaHours || !submittedAt) {
return {
state: 'untracked',
color: 'gray',
label: '—',
tooltip: 'No turnaround target is set for this licence type.',
ratio: 0,
};
}
const submitted = new Date(submittedAt).getTime();
const target = submitted + slaHours * HOUR_MS;
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
const window = slaHours * HOUR_MS;
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
if (application.decidedAt) {
const met = elapsed <= window;
return {
state: 'decided',
color: met ? 'teal' : 'gray',
label: met ? 'Met' : 'Missed',
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
ratio,
};
}
const remaining = target - now;
if (remaining < 0) {
return {
state: 'breached',
color: 'red',
label: `Overdue ${formatDuration(remaining)}`,
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
ratio: 1,
};
}
const used = elapsed / window;
return {
state: used >= WARNING_RATIO ? 'warning' : 'ok',
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
label: `${formatDuration(remaining)} left`,
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
ratio,
};
}

View File

@@ -0,0 +1,100 @@
import { useEffect } from 'react';
/**
* True when the user is typing, so a shortcut must not steal the keystroke.
*
* Without this, typing a company name into the search box would jump rows on
* every "j" and try to claim on every "c".
*/
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
target.isContentEditable
);
}
interface QueueKeyboardHandlers {
onNext: () => void;
onPrevious: () => void;
onOpen: () => void;
onClaim: () => void;
onEscape: () => void;
onHelp: () => void;
/** Disabled while a modal or drawer owns the keyboard. */
enabled?: boolean;
}
/**
* Queue keyboard navigation: j/k to move, Enter to open, c to claim.
*
* Officers work through a queue one row at a time all day; reaching for the
* mouse for each is the slow path. Modifier combinations are ignored so
* browser and OS shortcuts keep working.
*/
export function useQueueKeyboard({
onNext,
onPrevious,
onOpen,
onClaim,
onEscape,
onHelp,
enabled = true,
}: QueueKeyboardHandlers): void {
useEffect(() => {
if (!enabled) return;
const handler = (event: KeyboardEvent) => {
if (isTyping(event.target)) {
// Esc still works while typing — it is how you get out of the field.
if (event.key === 'Escape') onEscape();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
switch (event.key) {
case 'j':
event.preventDefault();
onNext();
break;
case 'k':
event.preventDefault();
onPrevious();
break;
case 'Enter':
event.preventDefault();
onOpen();
break;
case 'c':
event.preventDefault();
onClaim();
break;
case 'Escape':
onEscape();
break;
case '?':
event.preventDefault();
onHelp();
break;
default:
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [enabled, onNext, onPrevious, onOpen, onClaim, onEscape, onHelp]);
}
/** Rows shown in the `?` cheatsheet. */
export const KEYBOARD_SHORTCUTS: Array<{ keys: string; labelKey: string }> = [
{ keys: '⌘K', labelKey: 'shortcuts.commandPalette' },
{ keys: 'j / k', labelKey: 'shortcuts.moveRow' },
{ keys: 'Enter', labelKey: 'shortcuts.openRow' },
{ keys: 'c', labelKey: 'shortcuts.claimRow' },
{ keys: 'Esc', labelKey: 'shortcuts.dismiss' },
{ keys: '?', labelKey: 'shortcuts.help' },
];

View File

@@ -123,7 +123,7 @@ function TreeNode({
)}
</Box>
)}
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
{!hasChildren && <Box w={rem(18)} style={{ flexShrink: 0 }} />}
<IconMapPin
size={14}
stroke={1.5}

View File

@@ -16,6 +16,21 @@ export const am: Translations = {
nav: {
groupLicensing: 'ፈቃድ አሰጣጥ',
allApplications: 'ሁሉም ማመልከቻዎች',
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
byType: 'በዓይነት',
typeFreightForwarder: 'የጭነት አስተላላፊ',
typeShippingAgent: 'የመርከብ ወኪል',
typeCombined: 'ጥምር የመርከብ ወኪል እና ጭነት አስተላላፊ',
typeJointInvestment: 'የጋራ ኢንቨስትመንት',
typeMto: 'የብዝሃ-ሁነታ ትራንስፖርት አንቀሳቃሽ',
primary: 'ዋና',
destinations: 'ወደ',
noResults: 'ምንም አልተገኘም',
commandPlaceholder: 'ማያ ገጾችን፣ ማመልከቻዎችን፣ ኩባንያዎችን፣ ቲን ይፈልጉ…',
pending: 'በመጠባበቅ ላይ ያሉ',
pendingCount_one: '{{count}} በመጠባበቅ ላይ',
pendingCount_other: '{{count}} በመጠባበቅ ላይ',
groupSeafarer: 'የመርከበኞች አገልግሎት',
groupVessels: 'መርከቦች',
groupExaminations: 'ፈተናዎች',
@@ -602,4 +617,293 @@ export const am: Translations = {
departmentRequired: 'ክፍል ያስፈልጋል',
},
},
queue: {
title: 'የፈቃድ ማመልከቻዎች',
search: 'ፍለጋ',
searchPlaceholder: 'ኩባንያ፣ ቲን ወይም ቁጥር',
status: 'ሁኔታ',
anyStatus: 'ማንኛውም',
type: 'የፈቃድ ዓይነት',
anyType: 'ማንኛውም',
typeCol: 'ዓይነት',
statusCol: 'ሁኔታ',
submittedFrom: 'ከቀን ጀምሮ የቀረበ',
submittedTo: 'እስከ ቀን የቀረበ',
clearFilters: 'አጽዳ',
refresh: 'አድስ',
export: 'ወደ CSV ላክ',
exportTruncated: 'ወደ ውጭ መላክ ተቆርጧል',
exportTruncatedBody: 'ከ{{total}} ረድፎች ውስጥ የመጀመሪያዎቹ {{exported}} ተልከዋል። ለቀሪው ማጣሪያውን ያጥቡ።',
exportFailed: 'ወደ ውጭ መላክ አልተሳካም',
comfortable: 'ሰፊ',
compact: 'ጥብቅ',
number: 'ማመልከቻ ቁ.',
company: 'ኩባንያ',
tin: 'ቲን',
submitted: 'የቀረበበት',
sla: 'ዕድሜ / የጊዜ ገደብ',
claim: 'ውሰድ',
review: 'ገምግም',
claimed: 'ተወስዷል',
claimedBody: 'ማመልከቻው አሁን ለእርስዎ ተመድቧል።',
claimFailed: 'መውሰድ አልተቻለም',
claimRace: 'ሌላ ሹም አስቀድሞ ወስዶታል።',
bulkClaim_one: '{{count}} ውሰድ',
bulkClaim_other: '{{count}} ውሰድ',
bulkClaimed_one: '{{count}} ተወስዷል',
bulkClaimed_other: '{{count}} ተወስደዋል',
bulkClaimPartial_one: '{{count}} አስቀድሞ በሌላ ሹም ተወስዷል።',
bulkClaimPartial_other: '{{count}} አስቀድሞ በሌሎች ሹማምንት ተወስደዋል።',
selectAll: 'ሁሉንም ምረጥ',
selectRow: '{{number}} ምረጥ',
selectedCount_one: '{{count}} ተመርጧል',
selectedCount_other: '{{count}} ተመርጠዋል',
showing: 'ከ{{total}} ውስጥ {{from}}{{to}} በማሳየት ላይ',
empty: 'እዚህ የሚጠብቅ ነገር የለም',
emptyBody: 'አዲስ ማመልከቻዎች ሲቀርቡ እዚህ ይታያሉ።',
emptyFiltered: 'በእነዚህ ማጣሪያዎች የሚዛመድ ማመልከቻ የለም',
emptyFilteredBody: 'ማጣሪያዎቹን ለማስፋት ወይም ለማጽዳት ይሞክሩ።',
errorTitle: 'ወረፋውን መጫን አልተቻለም',
views: {
unassigned: 'ያልተመደበ',
mine: 'የእኔ ወረፋ',
awaitingApplicant: 'አመልካችን በመጠባበቅ',
overdue: 'ጊዜው ያለፈበት',
readyToIssue: 'ለመስጠት ዝግጁ',
all: 'ሁሉም',
},
},
review: {
summary: 'ማጠቃለያ',
officer: 'ሹም',
supervisor: 'የበላይ ኃላፊ',
officerPlaceholder: 'ማን እንደሚረከበው ይምረጡ',
noOfficers: 'ምንም ሹም አልተገኘም',
typeToConfirm: 'ለማረጋገጥ {{number}} ይተይቡ',
confirmMismatch: 'አይዛመድም',
type: 'ዓይነት',
tin: 'ቲን',
kind: 'ዓይነት',
submitted: 'የቀረበበት',
slaLabel: 'የጊዜ ገደብ',
eligibility: 'ብቁነት',
statusTimeline: 'ሂደት',
assigned: 'ተመድቧል',
decisionBar: 'የውሳኔ አሞሌ',
moreActions: 'ተጨማሪ ተግባራት',
irreversible: 'መመለስ አይቻልም',
irreversibleWarning: 'ይህ ውሳኔ የመጨረሻ ሲሆን ከጀርባ ቢሮ መመለስ አይቻልም።',
irreversibleAck: 'ይህ የመጨረሻ መሆኑን ተረድቻለሁ',
reasonCode: 'ምክንያት',
reasonCodePlaceholder: 'ምክንያት ይምረጡ',
reasonDetail: 'ለአመልካቹ ዝርዝር',
reasonDetailHint: 'ይህ ጽሑፍ ለአመልካቹ እንዳለ ይላካል።',
deficiencies: 'አመልካቹ ማስተካከል ያለበት ነገሮች',
deficienciesHint: 'የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።',
notificationPreview: 'ለአመልካቹ የሚላክ መልእክት',
notificationPreviewHint: 'በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።',
needsCorrection: 'ማስተካከያ ያስፈልገዋል',
correctionPlaceholder: 'አመልካቹ ምን ማስተካከል አለበት?',
verifiedCapital: 'የተረጋገጠ ካፒታል (ብር)',
capitalHint: 'ዝቅተኛ {{min}} — ከባንክ ደብዳቤ ጋር ያረጋግጡ',
capitalHintNoMin: 'ከባንክ ደብዳቤ ጋር ተረጋግጧል',
capitalLocked: 'በዚህ ደረጃ ካፒታል ማስተካከል አይቻልም።',
belowMinimum: 'ከ{{min}} ዝቅተኛ በታች',
declared: 'አመልካቹ ያሳወቀው',
role: 'ሚና',
name: 'ስም',
evidence: 'ማስረጃ',
noInspections: 'እስካሁን ምርመራ አልተያዘም።',
unscheduled: 'አልተያዘም',
inspectionResult: 'የምርመራ ውጤት',
findings: 'ግኝቶች',
dateTime: 'ቀን እና ሰዓት',
schedule: 'ያዝ',
pickDate: 'መጀመሪያ ቀን እና ሰዓት ይምረጡ',
passed: 'አልፏል',
failed: 'ወድቋል',
round_one: 'ዙር {{count}}',
round_other: 'ዙር {{count}}',
theApplicant: 'አመልካቹ',
linkCopied: 'አገናኝ ተቀድቷል',
actionFailed: 'ተግባሩ አልተሳካም',
errorTitle: 'ይህን ማመልከቻ መጫን አልተቻለም',
hideActivity: 'እንቅስቃሴ ደብቅ',
showActivity: 'እንቅስቃሴ አሳይ',
awaitingPayment: 'አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።',
tabs: {
overview: 'አጠቃላይ እይታ',
financials: 'የገንዘብ መረጃ',
documents: 'ሰነዶች',
staff: 'ሠራተኞች',
inspection: 'ምርመራ',
},
actions: {
claim: 'ውሰድ',
assign: 'መድብ',
escalate: 'ወደ ላይ አሳድግ',
hold: 'አግድ',
resume: 'ቀጥል',
completeReview: 'ግምገማ አጠናቅቅ',
approveDocuments: 'ሰነዶችን አጽድቅ',
scheduleInspection: 'ምርመራ ያዝ',
recordInspection: 'የምርመራ ውጤት መዝግብ',
finalApprove: 'አጽድቅ እና ስጥ',
requestAdjustment: 'ማስተካከያ ጠይቅ',
reject: 'አትቀበል',
confirmPayment: 'ክፍያ አረጋግጥ',
print: 'ሰነድ አትም',
copyLink: 'አገናኝ ቅዳ',
downloadDocuments: 'ሁሉንም ሰነዶች አውርድ',
generateCertificate: 'ሰርተፍኬት አዘጋጅ',
auditTrail: 'የኦዲት መዝገብ አሳይ',
},
disabled: {
wrongStatus: 'በዚህ ደረጃ አይገኝም',
notAssigned: 'ለሌላ ሹም ተመድቧል',
noPermission: 'ፈቃድ የለዎትም',
needsFlags: 'ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ',
needsCapital: 'መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ',
needsInspection: 'የምርመራ ውጤት ያስፈልጋል',
},
reasons: {
incompleteDocuments: 'ያልተሟሉ ሰነዶች',
belowCapital: 'ካፒታል ከሚያስፈልገው በታች',
failedInspection: 'ምርመራ ወድቋል',
ineligibleApplicant: 'አመልካቹ ብቁ አይደለም',
duplicateApplication: 'ተደጋጋሚ ማመልከቻ',
illegibleDocument: 'ሰነዱ አይነበብም',
expiredDocument: 'ሰነዱ ጊዜው አልፎበታል',
missingDocument: 'ሰነዱ ጠፍቷል',
inconsistentDetails: 'ዝርዝሮቹ ከሰነዶቹ ጋር አይዛመዱም',
awaitingThirdParty: 'የሶስተኛ ወገን ማረጋገጫ በመጠባበቅ',
legalProceedings: 'በሕግ ሂደት ላይ',
applicantRequest: 'በአመልካቹ ጥያቄ',
aboveAuthority: 'ከእኔ የማጽደቅ ሥልጣን በላይ',
policyUnclear: 'የፖሊሲ መመሪያ ያስፈልጋል',
conflictOfInterest: 'የጥቅም ግጭት',
},
consequences: {
fallback: 'ይህ ለ{{applicant}} ማመልከቻ {{number}} ያዘምናል።',
'final-approve': 'ለ{{applicant}} ማመልከቻ {{number}} ያጸድቃል እና የሰርተፍኬት አሰጣጥ ይጀምራል።',
reject: 'ለ{{applicant}} ማመልከቻ {{number}} አይቀበልም። ይህ ማመልከቻውን ያጠናቅቃል።',
'request-adjustment': 'ማመልከቻ {{number}} ለማስተካከያ ወደ {{applicant}} ይመልሳል።',
hold: 'ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።',
resume: 'ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።',
escalate: 'ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።',
'confirm-payment': 'ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።',
},
notifications: {
fallback: 'ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።',
'final-approve': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ጸድቋል። ሰርተፍኬትዎ በዝግጅት ላይ ነው።',
reject: 'ውድ {{applicant}}፣ ማመልከቻ {{number}} አልጸደቀም። እባክዎ ከታች ያለውን ምክንያት ይመልከቱ።',
'request-adjustment': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ከመቀጠሉ በፊት ማስተካከያ ያስፈልገዋል።',
},
activity: {
title: 'እንቅስቃሴ እና የኦዲት መዝገብ',
empty: 'እስካሁን የተመዘገበ እንቅስቃሴ የለም።',
system: 'ሲስተም',
officer: 'ሹም',
applicant: 'አመልካች',
remarkOn: 'በ{{target}} ላይ ማስተካከያ ተጠይቋል',
uploaded: '{{document}} ተጭኗል',
},
documents: {
completeness: 'የሚያስፈልጉ ሰነዶች',
accepted: 'ተቀባይነት አግኝቷል',
rejected: 'ተቀባይነት አላገኘም',
accept: 'ተቀበል',
clear: 'ውሳኔ አጽዳ',
confirmReject: 'አትቀበል',
includeInAdjustment: 'መልስ',
adjustmentNote: 'አመልካቹ ምን ማስተካከል አለበት?',
nothingToJudge: 'የሚገመገም ምንም አልተጫነም',
reviewedBy: 'በ{{name}} ተገምግሟል',
saveFailed: 'ውሳኔውን ማስቀመጥ አልተቻለም',
completenessLabel: '{{value}}% የሚያስፈልጉ ሰነዶች ተጭነዋል',
missing: 'እስካሁን አልተጫነም',
flagged: 'ማስተካከያ ተጠይቋል',
view: 'እይ',
preview: 'ቅድመ እይታ',
download: 'አውርድ',
downloadShort: 'አውርድ',
reject: 'አትቀበል',
rejectReason: 'ይህ ሰነድ ለምን መስተካከል አለበት?',
reasonRequired: 'ምክንያት ያስፈልጋል',
noFile: 'ፋይል የለም',
noFileUploaded: 'እስካሁን ምንም አልተጫነም',
noInlinePreview: 'ይህ የፋይል ዓይነት በአሳሹ ውስጥ ቅድመ እይታ አይደረግም።',
},
done: {
completeReview: 'ግምገማ ተጠናቋል',
approveDocuments: 'ሰነዶች ጸድቀዋል',
finalApprove: 'ጸድቋል',
requestAdjustment: 'ማስተካከያ ተጠይቋል',
reject: 'ማመልከቻው አልተቀበለም',
confirmPayment: 'ክፍያ ተረጋግጧል',
hold: 'ማመልከቻው ታግዷል',
resume: 'ማመልከቻው ቀጥሏል',
escalate: 'ወደ ላይ አድጓል',
assign: 'እንደገና ተመድቧል',
scheduled: 'ምርመራ ተይዟል',
inspectionPassed: 'ምርመራ አልፏል',
inspectionFailed: 'ምርመራ ወድቋል',
},
},
error: {
reference: 'ማጣቀሻ',
retry: 'እንደገና ሞክር',
},
shortcuts: {
title: 'የቁልፍ ሰሌዳ አቋራጮች',
commandPalette: 'ሁሉንም ፈልግ',
moveRow: 'በረድፎች መካከል ተንቀሳቀስ',
openRow: 'የተመረጠውን ረድፍ ክፈት',
claimRow: 'የተመረጠውን ረድፍ ውሰድ',
dismiss: 'ምርጫ አጽዳ / ዝጋ',
help: 'ይህን ዝርዝር አሳይ',
},
designer: {
title: 'የምስክር ወረቀት ንድፍ',
subtitle: 'ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።',
licenceType: 'የፈቃድ ዓይነት',
validityYears: 'የሚቆይበት (ዓመታት)',
validityHint: 'ፈቃድ ሲሰጥ ተግባራዊ ይሆናል',
saveValidity: 'የሚቆይበትን ጊዜ አስቀምጥ',
validitySaved: 'የሚቆይበት ጊዜ ተዘምኗል',
newVersion: 'አዲስ ስሪት',
versions: 'ስሪቶች',
name: 'የስሪት ስም',
landscape: 'አግድም',
source: 'ቅንብር (Handlebars + HTML)',
variables: 'ቦታ ያዢዎች',
variablesHint: 'በጠቋሚው ቦታ ለማስገባት ይጫኑ።',
preview: 'PDF ቅድመ እይታ',
previewFailed: 'ቅድመ እይታውን ማዘጋጀት አልተቻለም',
save: 'ረቂቅ አስቀምጥ',
saved: 'ረቂቅ ተቀምጧል',
saveFirst: 'መጀመሪያ ለውጦችዎን ያስቀምጡ',
publish: 'አትም',
published: 'ንድፉ ታትሟል',
publishHint: 'ይህንን ቀጥታ የምስክር ወረቀት ንድፍ ያደርገዋል',
publishedLocked: 'ይህ ስሪት ቀጥታ ላይ ስለሆነ ማስተካከል አይቻልም — ከእሱ የምስክር ወረቀቶች ተሰጥተዋል። ለውጥ ለማድረግ አዲስ ስሪት ይፍጠሩ።',
archive: 'አንሳ',
archived: 'ንድፉ ተነስቷል',
delete: 'ረቂቅ ሰርዝ',
deleted: 'ረቂቅ ተሰርዟል',
create: 'ፍጠር',
created: 'ረቂቅ ተፈጥሯል',
newHint: 'ከቀጥታ ንድፉ ወይም ይህ ዓይነት ከሌለው ከውስጠ-ግንብ ቅንብር ይጀምራል።',
empty: 'ለዚህ የፈቃድ ዓይነት እስካሁን ንድፍ የለም',
emptyBody: 'የምስክር ወረቀቶች አሁን ውስጠ-ግንብ ቅንብር ይጠቀማሉ። ለመቆጣጠር ስሪት ይፍጠሩ።',
loadFailed: 'ንድፎቹን መጫን አልተቻለም',
actionFailed: 'ተግባሩ አልተሳካም',
noPermission: 'ፈቃድ የለዎትም',
noPublishPermission: 'ንድፎችን ማተም አይችሉም',
},
};

View File

@@ -14,6 +14,21 @@ export const en = {
nav: {
groupLicensing: 'Licensing',
allApplications: 'All Applications',
certificateDesigner: 'Certificate Designer',
byType: 'By Type',
typeFreightForwarder: 'Freight Forwarder',
typeShippingAgent: 'Shipping Agent',
typeCombined: 'Combined SA + FF',
typeJointInvestment: 'Joint Investment',
typeMto: 'Multimodal Transport Operator',
primary: 'Primary',
destinations: 'Go to',
noResults: 'Nothing found',
commandPlaceholder: 'Search screens, applications, companies, TIN…',
pending: 'Items pending',
pendingCount_one: '{{count}} pending',
pendingCount_other: '{{count}} pending',
groupSeafarer: 'Seafarer Services',
groupVessels: 'Vessels',
groupExaminations: 'Examinations',
@@ -601,6 +616,295 @@ export const en = {
departmentRequired: 'Department is required',
},
},
queue: {
title: 'Licence applications',
search: 'Search',
searchPlaceholder: 'Company, TIN or number',
status: 'Status',
anyStatus: 'Any',
type: 'Licence type',
anyType: 'Any',
typeCol: 'Type',
statusCol: 'Status',
submittedFrom: 'Submitted from',
submittedTo: 'Submitted to',
clearFilters: 'Clear',
refresh: 'Refresh',
export: 'Export CSV',
exportTruncated: 'Export truncated',
exportTruncatedBody: 'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
exportFailed: 'Export failed',
comfortable: 'Comfortable',
compact: 'Compact',
number: 'App #',
company: 'Company',
tin: 'TIN',
submitted: 'Submitted',
sla: 'Age / SLA',
claim: 'Claim',
review: 'Review',
claimed: 'Claimed',
claimedBody: 'The application is now assigned to you.',
claimFailed: 'Could not claim',
claimRace: 'Another officer already claimed it.',
bulkClaim_one: 'Claim {{count}}',
bulkClaim_other: 'Claim {{count}}',
bulkClaimed_one: '{{count}} claimed',
bulkClaimed_other: '{{count}} claimed',
bulkClaimPartial_one: '{{count}} was already taken by another officer.',
bulkClaimPartial_other: '{{count}} were already taken by another officer.',
selectAll: 'Select all',
selectRow: 'Select {{number}}',
selectedCount_one: '{{count}} selected',
selectedCount_other: '{{count}} selected',
showing: 'Showing {{from}}{{to}} of {{total}}',
empty: 'Nothing waiting here',
emptyBody: 'New applications will appear here as they are submitted.',
emptyFiltered: 'No applications match these filters',
emptyFilteredBody: 'Try widening or clearing the filters.',
errorTitle: 'Could not load the queue',
views: {
unassigned: 'Unassigned',
mine: 'My Queue',
awaitingApplicant: 'Awaiting Applicant',
overdue: 'Overdue',
readyToIssue: 'Ready to Issue',
all: 'All',
},
},
review: {
summary: 'Summary',
officer: 'Officer',
supervisor: 'Supervisor',
officerPlaceholder: 'Select who takes this on',
noOfficers: 'No officers found',
typeToConfirm: 'Type {{number}} to confirm',
confirmMismatch: 'Does not match',
type: 'Type',
tin: 'TIN',
kind: 'Kind',
submitted: 'Submitted',
slaLabel: 'SLA',
eligibility: 'Eligibility',
statusTimeline: 'Progress',
assigned: 'Assigned',
decisionBar: 'Decision bar',
moreActions: 'More actions',
irreversible: 'Cannot be undone',
irreversibleWarning: 'This decision is final and cannot be undone from the backoffice.',
irreversibleAck: 'I understand this is final',
reasonCode: 'Reason',
reasonCodePlaceholder: 'Select a reason',
reasonDetail: 'Details for the applicant',
reasonDetailHint: 'This text is sent to the applicant verbatim.',
deficiencies: 'Items the applicant must correct',
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.',
needsCorrection: 'Needs correction',
correctionPlaceholder: 'What must the applicant correct?',
verifiedCapital: 'Verified capital (ETB)',
capitalHint: 'Minimum {{min}} — check against the bank letter',
capitalHintNoMin: 'Checked against the bank letter',
capitalLocked: 'Capital can no longer be edited at this stage.',
belowMinimum: 'Below the {{min}} minimum',
declared: 'Applicant declared',
role: 'Role',
name: 'Name',
evidence: 'Evidence',
noInspections: 'No inspection has been scheduled yet.',
unscheduled: 'Not scheduled',
inspectionResult: 'Inspection result',
findings: 'Findings',
dateTime: 'Date and time',
schedule: 'Schedule',
pickDate: 'Pick a date and time first',
passed: 'Passed',
failed: 'Failed',
round_one: 'round {{count}}',
round_other: 'round {{count}}',
theApplicant: 'the applicant',
linkCopied: 'Link copied',
actionFailed: 'Action failed',
errorTitle: 'Could not load this application',
hideActivity: 'Hide activity',
showActivity: 'Show activity',
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
tabs: {
overview: 'Overview',
financials: 'Financials',
documents: 'Documents',
staff: 'Staff',
inspection: 'Inspection',
},
actions: {
claim: 'Claim',
assign: 'Assign',
escalate: 'Escalate',
hold: 'Put on hold',
resume: 'Resume',
completeReview: 'Complete review',
approveDocuments: 'Approve documents',
scheduleInspection: 'Schedule inspection',
recordInspection: 'Record inspection result',
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
reject: 'Reject',
confirmPayment: 'Confirm payment',
print: 'Print dossier',
copyLink: 'Copy link',
downloadDocuments: 'Download all documents',
generateCertificate: 'Generate certificate',
auditTrail: 'Show audit trail',
},
disabled: {
wrongStatus: 'Not available at this stage',
notAssigned: 'Assigned to another officer',
noPermission: 'You do not have permission',
needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result',
},
reasons: {
incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum',
failedInspection: 'Failed inspection',
ineligibleApplicant: 'Applicant not eligible',
duplicateApplication: 'Duplicate application',
illegibleDocument: 'Document is illegible',
expiredDocument: 'Document has expired',
missingDocument: 'Document is missing',
inconsistentDetails: 'Details do not match the documents',
awaitingThirdParty: 'Awaiting third-party confirmation',
legalProceedings: 'Subject to legal proceedings',
applicantRequest: 'Requested by the applicant',
aboveAuthority: 'Above my approval authority',
policyUnclear: 'Policy guidance needed',
conflictOfInterest: 'Conflict of interest',
},
consequences: {
fallback: 'This updates application {{number}} for {{applicant}}.',
'final-approve': 'Approves application {{number}} for {{applicant}} and starts certificate issuance.',
reject: 'Rejects application {{number}} for {{applicant}}. This ends the application.',
'request-adjustment': 'Returns application {{number}} to {{applicant}} for correction.',
hold: 'Parks application {{number}} for {{applicant}}. It stays assigned to you.',
resume: 'Returns application {{number}} to the stage it was held from.',
escalate: 'Raises application {{number}} to a supervisor for a decision.',
'confirm-payment': 'Confirms settlement for application {{number}}.',
},
notifications: {
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
'final-approve': 'Dear {{applicant}}, application {{number}} has been approved. Your certificate is being prepared.',
reject: 'Dear {{applicant}}, application {{number}} has not been approved. Please see the reason below.',
'request-adjustment': 'Dear {{applicant}}, application {{number}} needs corrections before it can proceed.',
},
activity: {
title: 'Activity & audit trail',
empty: 'No activity recorded yet.',
system: 'System',
officer: 'Officer',
applicant: 'Applicant',
remarkOn: 'Correction requested on {{target}}',
uploaded: 'Uploaded {{document}}',
},
documents: {
completeness: 'Required documents',
accepted: 'Accepted',
rejected: 'Rejected',
accept: 'Accept',
clear: 'Clear verdict',
confirmReject: 'Reject',
includeInAdjustment: 'Send back',
adjustmentNote: 'What must the applicant correct?',
nothingToJudge: 'Nothing uploaded to judge',
reviewedBy: 'Reviewed by {{name}}',
saveFailed: 'Could not save the verdict',
completenessLabel: '{{value}}% of required documents uploaded',
missing: 'Not yet uploaded',
flagged: 'Correction requested',
view: 'View',
preview: 'Preview',
download: 'Download',
downloadShort: 'Download',
reject: 'Reject',
rejectReason: 'Why must this document be corrected?',
reasonRequired: 'A reason is required',
noFile: 'No file',
noFileUploaded: 'Nothing uploaded yet',
noInlinePreview: 'This file type cannot be previewed in the browser.',
},
done: {
completeReview: 'Review completed',
approveDocuments: 'Documents approved',
finalApprove: 'Approved',
requestAdjustment: 'Adjustment requested',
reject: 'Application rejected',
confirmPayment: 'Payment confirmed',
hold: 'Application placed on hold',
resume: 'Application resumed',
escalate: 'Escalated',
assign: 'Reassigned',
scheduled: 'Inspection scheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},
},
error: {
reference: 'Reference',
retry: 'Try again',
},
shortcuts: {
title: 'Keyboard shortcuts',
commandPalette: 'Search everything',
moveRow: 'Move between rows',
openRow: 'Open the selected row',
claimRow: 'Claim the selected row',
dismiss: 'Clear selection / close',
help: 'Show this list',
},
designer: {
title: 'Certificate designer',
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
licenceType: 'Licence type',
validityYears: 'Valid for (years)',
validityHint: 'Applied when a licence is issued',
saveValidity: 'Save validity',
validitySaved: 'Validity updated',
newVersion: 'New version',
versions: 'Versions',
name: 'Version name',
landscape: 'Landscape',
source: 'Template (Handlebars + HTML)',
variables: 'Placeholders',
variablesHint: 'Click to insert at the cursor.',
preview: 'Preview PDF',
previewFailed: 'Could not render the preview',
save: 'Save draft',
saved: 'Draft saved',
saveFirst: 'Save your changes first',
publish: 'Publish',
published: 'Design published',
publishHint: 'Makes this the live certificate design',
publishedLocked: 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
archive: 'Withdraw',
archived: 'Design withdrawn',
delete: 'Delete draft',
deleted: 'Draft deleted',
create: 'Create',
created: 'Draft created',
newHint: 'Starts from the live design, or the built-in layout if this type has none.',
empty: 'No design yet for this licence type',
emptyBody: 'Certificates currently use the built-in layout. Create a version to take control of it.',
loadFailed: 'Could not load the designs',
actionFailed: 'Action failed',
noPermission: 'You do not have permission',
noPublishPermission: 'You cannot publish designs',
},
};
export type Translations = typeof en;

View File

@@ -1,99 +1,28 @@
import { useCallback, useState } from 'react';
import { AppShell, rem } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { logout } from '@ema-platform/auth';
import { BrandMark, logout } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui';
import {
IconBook2,
IconChartBar,
IconCreditCard,
IconFileDescription,
IconHeart,
IconLayoutDashboard,
IconShieldCheck,
IconRubberStamp,
IconSettings,
IconUser,
IconUsers,
IconUserShield,
IconQuestionMark,
IconClipboardList,
IconReport,
IconAnchor,
IconFilePlus,
IconGauge,
IconShieldOff,
IconShip,
IconMapPin,
IconStack2,
IconTruck,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
import { useGetQueueCountsQuery } from '@ema-platform/api';
import { usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { NAV_SECTIONS } from './nav-config';
import { CommandPalette } from './CommandPalette';
/**
* Grouped so a reviewer can find things, and flagged so they can tell what
* actually works: `soon` marks screens with no backend behind them yet.
* How often the pending-work badges refresh.
*
* Polled on a timer rather than refetched per navigation: the counts sit in
* the chrome and are visible on every screen, so tying them to route changes
* would fire a request each time an officer clicked anything.
*/
const NAV_SECTIONS: NavSection[] = [
{
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
},
{
label: 'nav.groupLicensing',
items: [
{ to: '/licence-review', label: 'nav.licenceReview', icon: IconTruck },
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
],
},
{
label: 'nav.groupSeafarer',
items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
],
},
{
label: 'nav.groupExaminations',
items: [
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
],
},
{
label: 'nav.groupAdministration',
items: [
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
{ to: '/profile', label: 'nav.profile', icon: IconUser },
],
},
];
/** Flat list used for breadcrumbs and active-route lookup. */
const NAV_ITEMS: NavItem[] = NAV_SECTIONS.flatMap((section) => section.items);
const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116;
@@ -106,6 +35,44 @@ export function BackofficeLayout() {
const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { can } = usePermissions();
// Badges reflect real pending work. One grouped request on a timer, shared
// by the sidebar and the top bar via the RTK cache.
const { data: counts } = useGetQueueCountsQuery(undefined, {
pollingInterval: BADGE_POLL_MS,
refetchOnMountOrArgChange: false,
});
const sections = useMemo<NavSection[]>(() => {
const withBadges = NAV_SECTIONS.map((section) => ({
...section,
items: section.items.map((item) =>
item.to === '/licence-review' && counts?.unassigned
? { ...item, badge: counts.unassigned }
: item,
),
}));
return filterByPermissions(
withBadges,
// `can` already fails open when the token carries no permission claim,
// so this only ever removes items we are sure the user cannot use.
withBadges
.flatMap((section) => section.items)
.flatMap((item) => [item, ...(item.children ?? [])])
.flatMap((item) => item.permissions ?? [])
.filter((permission) => can([permission])),
);
}, [counts?.unassigned, can]);
/** Flat list used for breadcrumbs and active-route lookup. */
const navItems = useMemo<NavItem[]>(
() =>
sections.flatMap((section) =>
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
),
[sections],
);
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
@@ -128,7 +95,7 @@ export function BackofficeLayout() {
.filter((path) => path !== '/dashboard')
.filter((path) => !path.startsWith('/um'))
.map((path) => {
const match = NAV_ITEMS.find((item) => item.to === path);
const match = navItems.find((item) => item.to === path);
if (match) return { label: t(match.label), path };
const segment = path.split('/').pop() ?? '';
// Ids get a generic label rather than a raw uuid in the trail.
@@ -195,58 +162,19 @@ export function BackofficeLayout() {
style={{
display: 'flex',
alignItems: 'center',
gap: rem(2),
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{NAV_ITEMS.map((item) => {
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
const ItemIcon = item.icon;
return (
<button
key={item.to}
onClick={() => go(item)}
title={item.soon ? `${t(item.label)}${t('nav.soon', 'Soon')}` : undefined}
style={{
opacity: item.soon ? 0.55 : 1,
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: '8px 16px',
border: 'none',
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
background: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'all 150ms ease',
height: '100%',
marginBottom: -1,
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
<ItemIcon size={18} stroke={1.6} />
<span>{t(item.label)}</span>
</button>
);
})}
{/* Grouped dropdowns. Previously every destination rendered as a
sibling button in one horizontally scrolling row. */}
<AppTopNav
navItems={sections}
activePath={location.pathname}
onNavigate={go}
/>
</div>
)}
</AppShell.Header>
@@ -262,13 +190,14 @@ export function BackofficeLayout() {
}}
>
<AppSidebar
navItems={NAV_SECTIONS}
navItems={sections}
collapsed={collapsed}
activePath={location.pathname}
onToggleCollapse={handleToggleCollapse}
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
brandLogo={<BrandMark size={32} />}
/>
</AppShell.Navbar>
)}
@@ -278,6 +207,9 @@ export function BackofficeLayout() {
<Outlet />
</div>
</AppShell.Main>
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
<CommandPalette sections={sections} />
</AppShell>
);
}

View File

@@ -0,0 +1,96 @@
import { useMemo, useState } from 'react';
import { Spotlight, type SpotlightActionData } from '@mantine/spotlight';
import { useDebouncedValue } from '@mantine/hooks';
import { IconFileText, IconSearch } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { flattenNav, type NavSection } from '@ema-platform/ui';
import { useGetAllApplicationsQuery } from '@ema-platform/api';
/** Long enough that typing a company name does not fire a request per keystroke. */
const SEARCH_DEBOUNCE_MS = 250;
/** Below this, a server search matches too much to be useful. */
const MIN_SEARCH_LENGTH = 2;
interface CommandPaletteProps {
/** Already permission-filtered, so the palette cannot reach a hidden route. */
sections: NavSection[];
}
/**
* ⌘K search over every destination and recent application.
*
* With twenty-plus destinations plus five licence types, hunting through
* nested menus is the slow path. This makes nesting cheap: anything reachable
* by clicking is reachable by typing, including applications by number,
* company or TIN.
*/
export function CommandPalette({ sections }: CommandPaletteProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [debounced] = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const term = debounced.trim();
// Only hits the API once the palette is open and the query is meaningful.
const { data: applications } = useGetAllApplicationsQuery(
{ search: term, take: 8 },
{ skip: term.length < MIN_SEARCH_LENGTH },
);
const destinationActions = useMemo<SpotlightActionData[]>(
() =>
flattenNav(sections)
.filter((item) => item.to && !item.soon)
.map((item) => ({
id: item.to as string,
label: t(item.label),
description: item.to,
leftSection: <item.icon size={18} stroke={1.6} />,
onClick: () => navigate(item.to as string),
})),
[sections, navigate, t],
);
const applicationActions = useMemo<SpotlightActionData[]>(
() =>
(applications?.items ?? []).map((app) => ({
id: `application-${app.id}`,
label: app.companyName ?? app.applicationNumber,
description: [app.applicationNumber, app.tinNumber]
.filter(Boolean)
.join(' · '),
leftSection: <IconFileText size={18} stroke={1.6} />,
onClick: () => navigate(`/licence-review/${app.id}`),
})),
[applications, navigate],
);
return (
<Spotlight
query={query}
onQueryChange={setQuery}
actions={[
{
group: t('nav.destinations', 'Go to'),
actions: destinationActions,
},
{
group: t('nav.applications', 'Applications'),
actions: applicationActions,
},
]}
shortcut={['mod + K']}
nothingFound={t('nav.noResults', 'Nothing found')}
highlightQuery
searchProps={{
leftSection: <IconSearch size={18} stroke={1.6} />,
placeholder: t(
'nav.commandPlaceholder',
'Search screens, applications, companies, TIN…',
),
}}
/>
);
}

View File

@@ -0,0 +1,144 @@
import {
IconAnchor,
IconBook2,
IconChartBar,
IconClipboardList,
IconCreditCard,
IconFileDescription,
IconFilePlus,
IconGauge,
IconHeart,
IconLayoutDashboard,
IconListCheck,
IconMapPin,
IconQuestionMark,
IconReport,
IconRosetteDiscountCheck,
IconRubberStamp,
IconSettings,
IconShieldCheck,
IconShieldOff,
IconShip,
IconTruck,
IconUsers,
IconUserShield,
} from '@tabler/icons-react';
import type { NavSection } from '@ema-platform/ui';
/**
* Permission keys mirrored from the API's `LICENSE_PERMISSIONS`.
*
* Kept as literals rather than imported: the backoffice bundle must not pull
* in server code, and these strings are a published contract — the IAM seed
* and every `PermissionGuard([...])` already read from the same list.
*/
export const PERMISSIONS = {
VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue',
VIEW_APPLICATIONS: 'can:View:license-applications',
VIEW_LICENSE_TYPES: 'can:View:license-types',
VIEW_PAYMENTS: 'can:View:license-payments',
VIEW_TEMPLATES: 'can:View:license-templates',
UPDATE_TEMPLATE: 'can:update:license-template',
PUBLISH_TEMPLATE: 'can:publish:license-template',
} as const;
/**
* The backoffice information architecture.
*
* Six top-level groups, none deeper than one level of nesting. `soon` marks
* screens with no backend behind them, so a reviewer can tell at a glance what
* actually works.
*/
export const NAV_SECTIONS: NavSection[] = [
{
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
},
{
label: 'nav.groupLicensing',
items: [
{
to: '/licence-review',
label: 'nav.allApplications',
icon: IconListCheck,
permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE],
},
{
// A disclosure, not a destination — each child deep-links the grid to
// one type, which is a facet of the same workspace.
label: 'nav.byType',
icon: IconTruck,
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
children: [
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck },
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip },
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription },
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers },
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor },
],
},
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck,
permissions: [PERMISSIONS.VIEW_TEMPLATES],
},
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
{
to: '/logistics-head-dashboard',
label: 'nav.logisticsHeadDashboard',
icon: IconGauge,
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [PERMISSIONS.VIEW_PAYMENTS],
},
],
},
{
label: 'nav.groupSeafarer',
items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
],
},
{
label: 'nav.groupExaminations',
items: [
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
],
},
{
label: 'nav.groupAdministration',
items: [
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
{
to: '/configuration',
label: 'nav.configuration',
icon: IconSettings,
permissions: [PERMISSIONS.VIEW_LICENSE_TYPES],
},
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
],
},
// `/profile` deliberately absent: it is a property of the signed-in user,
// not a destination in the authority's workload, and now lives in the
// AppHeader user menu alongside sign-out.
];

View File

@@ -42,6 +42,7 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
const router = createBrowserRouter([
{
@@ -89,7 +90,11 @@ const router = createBrowserRouter([
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
// Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
{ path: 'licence-review', element: <LicenseQueuePage /> },
// Deep link into the grid with the type facet pinned, so "Freight
// Forwarder" in the nav is a filtered view rather than a page.
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },

View File

@@ -2,8 +2,15 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type LayoutMode = 'top' | 'sidebar';
/**
* Row height in tables. Officers who work a queue all day want more rows per
* screen; occasional users want the breathing room.
*/
export type Density = 'comfortable' | 'compact';
interface PreferencesState {
layoutMode: LayoutMode;
density: Density;
}
const PREFERENCES_KEY = 'ema-backoffice-preferences';
@@ -11,17 +18,25 @@ const PREFERENCES_KEY = 'ema-backoffice-preferences';
const loadPreferences = (): PreferencesState => {
try {
const stored = localStorage.getItem(PREFERENCES_KEY);
if (stored) return JSON.parse(stored);
} catch {}
if (stored) {
// Merge over the defaults so a preferences blob written before a new
// key existed does not come back with that key undefined.
return { layoutMode: 'sidebar', density: 'comfortable', ...JSON.parse(stored) };
}
} catch {
// Corrupt or unavailable storage (private mode) — fall through to the default.
}
// The sidebar is the grouped, scannable layout; the top strip puts all
// ~20 destinations in one horizontally-scrolling row.
return { layoutMode: 'sidebar' };
return { layoutMode: 'sidebar', density: 'comfortable' };
};
const savePreferences = (state: PreferencesState) => {
try {
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
} catch {}
} catch {
// Storage full or unavailable — the preference just will not persist.
}
};
const initialState: PreferencesState = loadPreferences();
@@ -34,8 +49,12 @@ const preferencesSlice = createSlice({
state.layoutMode = action.payload;
savePreferences(state);
},
setDensity(state, action: PayloadAction<Density>) {
state.density = action.payload;
savePreferences(state);
},
},
});
export const { setLayoutMode } = preferencesSlice.actions;
export const { setLayoutMode, setDensity } = preferencesSlice.actions;
export const preferencesReducer = preferencesSlice.reducer;

View File

@@ -3,10 +3,32 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/spotlight/styles.css';
import './styles.css';
import './app/i18n/config';
import { App } from './app/app';
/**
* Branding handed to the vendored `@tria-plc/iamui` user-management module,
* which reads it off `window` at import time. Declared here because that
* package ships no ambient type for it.
*/
declare global {
interface Window {
__USER_MANAGEMENT_BRANDING__: {
appName: string;
organizationName: string;
logoSrc: string;
logoAlt: string;
homePath: string;
moduleBasePath: string;
backToAppPath: string;
backToAppLabel: string;
cssVariables: Record<string, string>;
};
}
}
document.title = 'EMA Backoffice';
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');

View File

@@ -4,3 +4,51 @@
@tailwind utilities;
*, *::before, *::after { box-sizing: border-box; }
/* ---------------------------------------------------------------------------
Print — the review dossier.
An officer printing a review wants the application, not the application
plus the chrome around it. Navigation, the Decision Bar and the collapsible
rails are all interactive surfaces with no meaning on paper, so they are
dropped and the centre column is given the full width.
--------------------------------------------------------------------------- */
@media print {
.mantine-AppShell-navbar,
.mantine-AppShell-header,
[role='region'][aria-label='Decision bar'],
.mantine-Drawer-root,
.mantine-Modal-root {
display: none !important;
}
.mantine-AppShell-main {
padding: 0 !important;
}
/* Tabs print flattened: a printed dossier that shows one tab's worth of a
six-tab application is missing five sixths of the record. */
.mantine-Tabs-panel {
display: block !important;
}
.mantine-Tabs-list {
display: none !important;
}
/* Sticky positioning collapses badly across page breaks. */
[style*='position: sticky'],
[style*='position:sticky'] {
position: static !important;
}
/* Keep a record entry from being split across two sheets. */
.mantine-Paper-root,
.mantine-Card-root,
.mantine-Table-tr {
break-inside: avoid;
}
body {
background: #fff !important;
}
}

View File

@@ -1,17 +0,0 @@
import { Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { authStorage } from '@ema-platform/auth';
interface ProfileGuardProps {
children?: ReactNode;
}
export function ProfileGuard({ children }: ProfileGuardProps) {
const profileId = authStorage.getProfileId();
if (!profileId) {
return <Navigate to="/profile-setup" replace />;
}
return <>{children}</>;
}

View File

@@ -34,6 +34,7 @@ import {
IconFileText,
IconShieldCheck,
} from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
import {
APPLICANT_ACTION_STATUSES,
STATUS_COLORS,
@@ -129,6 +130,9 @@ export function DashboardPage() {
licenseCount={activeLicenses.length}
/>
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
<ProfileCompletionNudge />
{needsMe.length > 0 && (
<ActionRequired applications={needsMe} navigate={navigate} />
)}

View File

@@ -66,6 +66,29 @@ export function MyApplicationsPage() {
}
}
/**
* Opens the certificate belonging to an application.
*
* The applicant knows their application number, not the licence id, so the
* licence is looked up from the list already loaded rather than making them
* find it in a separate table.
*/
async function openCertificateForApplication(applicationId: string) {
const licence = (licences?.items ?? []).find(
(l) => l.applicationId === applicationId,
);
if (!licence) {
notifications.show({
color: 'yellow',
title: 'Certificate not ready',
message:
'The licence for this application has not been issued yet. It will appear under My licences.',
});
return;
}
await downloadCertificate(licence.id);
}
async function downloadCertificate(licenseId: string) {
try {
const { url } = await getCertificateUrl(licenseId).unwrap();
@@ -234,6 +257,19 @@ export function MyApplicationsPage() {
Bypass payment
</Button>
)}
{/* An issued application's primary action is the
certificate. It used to be "View", which opened the
application wizard — so the one thing the applicant
came back for was the one thing the button did not do. */}
{app.status === 'CERTIFICATE_ISSUED' && (
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => openCertificateForApplication(app.id)}
>
Certificate
</Button>
)}
<Button
size="xs"
loading={isPaying && app.status === 'PAYMENT_PENDING'}
@@ -266,9 +302,8 @@ export function MyApplicationsPage() {
? 'Fix & resubmit'
: app.status === 'PAYMENT_PENDING'
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
: app.status === 'PAYMENT_CONFIRMED' ||
app.status === 'PAID'
? 'View'
: app.status === 'CERTIFICATE_ISSUED'
? 'Application'
: 'View'}
</Button>
</Group>

View File

@@ -1,378 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Center,
Group,
Paper,
Stack,
Text,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconLogout2,
IconMapPin,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
ProfileFormContent,
profileSchema,
type ProfileValues,
} from '../../profile/components/ProfileFormContent';
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../../profile/components/AddressFormContent';
const STEPS = [
{ label: 'Profile', icon: IconUser },
{ label: 'Address', icon: IconMapPin },
];
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
export function ProfileSetupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
const [meTrigger] = useApiMutation<AuthUser>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
.finally(() => setProfessionsLoading(false));
}, [fetchProfessions]);
const professionOptions = useMemo(
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
[professions],
);
const professionNameMap = useMemo(() => {
const map: Record<string, string> = {};
professions.forEach((p) => {
map[p.id] = p.name.en;
});
return map;
}, [professions]);
const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
const profileDefaults: ProfileValues = useMemo(() => ({
professionId: '',
firstName: nameParts[0] || '',
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
gender: '',
dob: '',
pob: '',
maritalStatus: '',
}), [nameParts]);
const addressDefaults: AddressValues = useMemo(() => ({
idType: '',
idNumber: '',
nationality: '',
primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: '',
email: user?.email || '',
regionId: '',
cityId: '',
subcityId: '',
woredaId: '',
kebeleId: '',
streetAddress: '',
postalAddress: '',
emergencyContactName: '',
emergencyContactPhone: '',
emergencyContactRelation: '',
}), [user]);
const {
register: profileRegister,
handleSubmit: profileHandleSubmit,
formState: { errors: profileErrors },
setValue: profileSetValue,
watch: profileWatch,
trigger: profileTriggerValidation,
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: profileDefaults,
});
const {
register: addressRegister,
handleSubmit: addressHandleSubmit,
formState: { errors: addressErrors },
setValue: addressSetValue,
watch: addressWatch,
trigger: addressTriggerValidation,
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
defaultValues: addressDefaults,
});
const onNext = async () => {
const valid = await profileTriggerValidation();
if (!valid) {
// Without this the button silently does nothing, which reads as broken
// when the offending field is off-screen.
notify.error('Please complete the highlighted fields before continuing.');
return;
}
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
setActive((c) => c + 1);
};
const onSubmitAddress = async () => {
const valid = await addressTriggerValidation();
if (!valid) {
notify.error('Please complete the highlighted fields before continuing.');
return;
}
setSubmitting(true);
try {
const pv = profileWatch();
const av = addressWatch();
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
const profileResult = await profileTrigger({
url: '/profiles',
method: 'POST',
body: {
userId: user?.id,
type: 'SEAFARER',
professionId: pv.professionId,
firstName: pv.firstName,
middleName: pv.middleName,
lastName: pv.lastName,
gender: pv.gender,
dob: pv.dob,
pob: pv.pob || undefined,
maritalStatus: pv.maritalStatus,
},
}).unwrap();
authStorage.setProfileId(profileResult.id);
await addressTrigger({
url: `/addresss/profile/${profileResult.id}`,
method: 'POST',
body: {
idType: av.idType,
idNumber: av.idNumber,
nationality: av.nationality,
primaryPhoneNumber: av.primaryPhoneNumber,
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
email: av.email || undefined,
regionId: av.regionId || undefined,
cityId: av.cityId || undefined,
subcityId: av.subcityId || undefined,
woredaId: av.woredaId || undefined,
kebeleId: av.kebeleId || undefined,
streetAddress: av.streetAddress || undefined,
postalAddress: av.postalAddress || undefined,
emergencyContactName: av.emergencyContactName || undefined,
emergencyContactPhone: av.emergencyContactPhone || undefined,
emergencyContactRelation: av.emergencyContactRelation || undefined,
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
notify.success('Profile setup complete!');
navigate('/dashboard');
} catch {
notify.error('Failed to save profile. Please try again.');
} finally {
setSubmitting(false);
}
};
if (!user) {
return (
<Center mih="100vh">
<Text c="dimmed">Please log in first.</Text>
</Center>
);
}
return (
<Center mih="100vh" bg="gray.0">
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
<Stack gap="md">
<div>
<Title order={3}>Complete Your Profile</Title>
<Text fz="sm" c="dimmed">
Set up your profile and address to get started
</Text>
</div>
<StepIndicator active={active} completed={completed} />
{active === 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information
</Text>
<ProfileFormContent
register={profileRegister}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</>
)}
{active === 1 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact
</Text>
<AddressFormContent
register={addressRegister}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
color="gray"
leftSection={<IconLogout2 size={16} />}
onClick={() => {
dispatch(logout());
navigate('/login');
}}
>
Sign out
</Button>
<Group gap="sm">
{active > 0 && (
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActive((c) => c - 1)}
>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={onSubmitAddress}
loading={submitting}
>
Complete Setup
</Button>
)}
</Group>
</Group>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -0,0 +1,119 @@
import { useCallback, useState } from 'react';
import {
ActionIcon,
Button,
Group,
Paper,
RingProgress,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PROFILE_FIELD_SECTION, useCurrentProfile } from '@ema-platform/auth';
const DISMISS_KEY = 'ema-portal-profile-nudge-dismissed';
/** Nothing below this is worth interrupting anyone about. */
const NUDGE_THRESHOLD = 100;
/** How many gaps to name before falling back to a count. */
const MAX_LISTED_GAPS = 3;
function readDismissed(): boolean {
try {
return localStorage.getItem(DISMISS_KEY) === 'true';
} catch {
// Private mode — treat as not dismissed rather than hiding the nudge.
return false;
}
}
/**
* A prompt to finish the profile. Explicitly not a gate.
*
* The applicant can dismiss it, and dismissing it persists. It never prevents
* navigation and never appears on top of anything — replacing the wizard with
* a modal would just be the same wall in a smaller box.
*/
export function ProfileCompletionNudge() {
const { t } = useTranslation();
const { completeness, missing, isLoading } = useCurrentProfile();
const [dismissed, setDismissed] = useState(readDismissed);
const dismiss = useCallback(() => {
setDismissed(true);
try {
localStorage.setItem(DISMISS_KEY, 'true');
} catch {
// Not persisting a dismissal is a smaller problem than crashing here.
}
}, []);
if (isLoading || dismissed || completeness >= NUDGE_THRESHOLD) return null;
const topGaps = missing.slice(0, MAX_LISTED_GAPS);
const remaining = missing.length - topGaps.length;
const firstSection = topGaps.length ? PROFILE_FIELD_SECTION[topGaps[0]] : 'personal';
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group wrap="nowrap" gap="md" align="center">
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: completeness, color: 'emaPrimary' }]}
label={
<Text ta="center" fw={700} size="xs">
{completeness}%
</Text>
}
/>
<Stack gap={2}>
<Text fw={600} size="sm">
{t('profileNudge.title', 'Finish setting up your profile')}
</Text>
<Text size="xs" c="dimmed">
{t('profileNudge.body', {
fields: topGaps
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
.join(', '),
defaultValue: 'Still needed: {{fields}}',
})}
{remaining > 0 &&
` ${t('profileNudge.andMore', {
count: remaining,
defaultValue: 'and {{count}} more',
})}`}
</Text>
</Stack>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
component={Link}
to={`/profile#${firstSection}`}
size="xs"
variant="light"
>
{t('profileNudge.action', 'Complete profile')}
</Button>
<Tooltip label={t('profileNudge.dismiss', 'Dismiss')}>
<ActionIcon
variant="subtle"
color="gray"
onClick={dismiss}
aria-label={t('profileNudge.dismiss', 'Dismiss')}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,91 @@
import { Alert, Anchor, Button, Group, List, Stack, Text } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
PROFILE_FIELD_SECTION,
useCurrentProfile,
type ProfileRequirement,
} from '@ema-platform/auth';
import type { ReactNode } from 'react';
interface ProfileRequirementGateProps {
requirement: ProfileRequirement;
/** Rendered once the requirement is satisfied. */
children: ReactNode;
/**
* When true the children still render alongside the notice. Use for flows
* the applicant can keep working through while a detail is outstanding.
*/
advisory?: boolean;
}
/**
* Asks for missing profile details in place.
*
* Deliberately not a redirect. Sending someone to /profile mid-application
* loses their work and their place, which is what the old setup wizard did at
* a larger scale. This renders an inline card naming exactly which fields are
* outstanding and links to the tab that collects them, so the applicant can
* fill them in a second tab and come back.
*/
export function ProfileRequirementGate({
requirement,
children,
advisory = false,
}: ProfileRequirementGateProps) {
const { t } = useTranslation();
const { gapsFor, isLoading } = useCurrentProfile();
// Never block on the resolver: showing the flow and letting submission fail
// is better than a spinner over content that is probably fine.
if (isLoading) return <>{children}</>;
const gaps = gapsFor(requirement);
if (gaps.length === 0) return <>{children}</>;
const sections = [...new Set(gaps.map((field) => PROFILE_FIELD_SECTION[field]))];
return (
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={18} />}
title={t('profileGate.title', {
count: gaps.length,
defaultValue: 'We need {{count}} more detail before you continue',
defaultValue_other: 'We need {{count}} more details before you continue',
})}
>
<Stack gap="xs">
<Text size="sm">{requirement.reason}</Text>
<List size="sm" spacing={2}>
{gaps.map((field) => (
<List.Item key={field}>
{t(`profileFields.${field}`, { defaultValue: field })}
</List.Item>
))}
</List>
<Group gap="xs">
{sections.map((section) => (
<Button
key={section}
component={Link}
to={`/profile#${section}`}
size="xs"
variant="light"
>
{t('profileGate.addDetails', 'Add these details')}
</Button>
))}
<Anchor component={Link} to="/profile" size="xs" c="dimmed">
{t('profileGate.viewProfile', 'View full profile')}
</Anchor>
</Group>
</Stack>
</Alert>
{advisory && children}
</Stack>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Box,
@@ -9,6 +9,7 @@ import {
Loader,
Paper,
PasswordInput,
RingProgress,
SimpleGrid,
Stack,
Switch,
@@ -16,6 +17,7 @@ import {
Text,
TextInput,
Title,
Tooltip,
UnstyledButton,
useMantineColorScheme,
type MantineColorScheme,
@@ -39,13 +41,14 @@ import {
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
import { setUser, useCurrentProfile } from '@ema-platform/auth';
import type { CurrentProfile } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -62,6 +65,9 @@ import {
} from '../components/AddressFormContent';
import classes from './ProfilePage.module.css';
/** Tab keys addressable via the URL hash. */
const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences'];
function getInitials(name: string, fallback: string) {
const source = name?.trim() || fallback?.trim() || '';
if (!source) return '?';
@@ -84,7 +90,7 @@ export function ProfilePage() {
const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [updateTrigger] = useApiMutation<AuthUser>();
@@ -126,8 +132,17 @@ export function ProfilePage() {
return map;
}, [professions]);
// ---- Profile data (from stored currentProfile) ----
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
// ---- Profile data ----
// Resolved through `useCurrentProfile`, which provisions a profile if the
// user has none. The page used to read an id out of local storage that only
// the deleted setup wizard ever wrote, so it rendered an empty form forever
// for anyone who signed up after the wizard was removed.
const {
profile: resolvedProfile,
isLoading: profileResolving,
completeness,
missing,
} = useCurrentProfile();
const [updateProfile] = useApiMutation<unknown>();
const [updateAddress] = useApiMutation<unknown>();
@@ -136,9 +151,28 @@ export function ProfilePage() {
const [profileId, setProfileId] = useState<string | null>(null);
const [addressId, setAddressId] = useState<string | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const profileFetched = useRef(false);
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
// requirement gates link straight at them (/profile#address), so the hash
// has to select a tab rather than being ignored. Emergency-contact fields
// live inside the address form, so both anchors land on that tab.
const tabFromHash = useCallback((hash: string) => {
const key = hash.replace('#', '');
if (key === 'emergency') return 'address';
return VALID_TABS.includes(key) ? key : 'personal';
}, []);
const [activeTab, setActiveTab] = useState(() =>
tabFromHash(typeof window === 'undefined' ? '' : window.location.hash),
);
const { hash } = useLocation();
useEffect(() => {
setActiveTab(tabFromHash(hash));
}, [hash, tabFromHash]);
useEffect(() => {
// Prefer the freshly resolved profile; fall back to whatever the store
// already holds so the form does not flash empty on a refetch.
const currentProfile = resolvedProfile ?? storedProfile;
if (currentProfile) {
setProfileId(currentProfile.id);
setLoadedProfile({
@@ -177,29 +211,12 @@ export function ProfilePage() {
});
}
setDataLoading(false);
} else if (user && !profileFetched.current) {
profileFetched.current = true;
const profileId = authStorage.getProfileId();
if (profileId) {
const q = `w=user_id:=:${user.id}&i=user,address,profession`;
fetchProfile({ url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET' })
.unwrap()
.then((result) => {
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
dispatch(setCurrentProfile(profile));
} else {
setDataLoading(false);
}
})
.catch(() => setDataLoading(false));
} else {
setDataLoading(false);
}
} else {
} else if (!profileResolving) {
// Resolver finished and there is still nothing — render the empty form
// rather than an indefinite spinner.
setDataLoading(false);
}
}, [currentProfile, user, fetchProfile, dispatch]);
}, [resolvedProfile, storedProfile, profileResolving]);
// Load the latest user from the server on mount
useEffect(() => {
@@ -209,7 +226,9 @@ export function ProfilePage() {
.then((me) => {
if (active) dispatch(setUser(me));
})
.catch(() => {});
.catch(() => {
// Best-effort refresh; the store already holds the user from sign-in.
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -449,12 +468,50 @@ export function ProfilePage() {
{user.username}
</Badge>
)}
{/* Completeness. Informational only — nothing here blocks the user,
it just makes visible what the nudge and the in-flow gates are
reacting to. Computed by the API so all three agree. */}
<Tooltip
label={
missing.length
? missing
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
.join(', ')
: t('profileSections.sectionSaved', 'Saved')
}
multiline
w={260}
withArrow
>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: completeness, color: 'emaPrimary' }]}
aria-label={t('profileSections.completeness', {
value: completeness,
defaultValue: '{{value}}% complete',
})}
label={
<Text ta="center" fw={700} size="xs">
{completeness}%
</Text>
}
/>
</Tooltip>
</Group>
</Paper>
{/* Tabs */}
<Tabs
defaultValue="personal"
value={activeTab}
onChange={(value) => {
const next = value ?? 'personal';
setActiveTab(next);
// Keep the URL shareable without pushing a history entry per tab.
window.history.replaceState(null, '', `#${next}`);
}}
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>

View File

@@ -29,7 +29,6 @@ import {
IconTransferIn,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Types
@@ -124,9 +123,12 @@ export function VesselRegistrationPage() {
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
// `/vessel-registrations/my` resolves the owner from the token, so it never
// needed a profile id. Gating on one meant anyone who signed up after the
// setup wizard was removed — and so had nothing in local storage — silently
// never loaded their registration.
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()

View File

@@ -89,6 +89,56 @@ export const am: Translations = {
quickActions: 'ፈጣን ድርጊቶች',
},
/** Field labels shared by the completeness nudge and the requirement gates. */
profileFields: {
firstName: 'የመጀመሪያ ስም',
middleName: 'የአባት ስም',
lastName: 'የአያት ስም',
gender: 'ጾታ',
dob: 'የትውልድ ቀን',
pob: 'የትውልድ ቦታ',
maritalStatus: 'የጋብቻ ሁኔታ',
professionId: 'ሙያ',
idType: 'የመታወቂያ ዓይነት',
idNumber: 'የመታወቂያ ቁጥር',
nationality: 'ዜግነት',
primaryPhoneNumber: 'ስልክ ቁጥር',
email: 'ኢሜይል አድራሻ',
regionId: 'ክልል',
cityId: 'ከተማ',
subCityId: 'ክፍለ ከተማ',
woredaId: 'ወረዳ',
streetAddress: 'የመንገድ አድራሻ',
emergencyContactName: 'የአደጋ ጊዜ ተጠሪ ስም',
emergencyContactPhone: 'የአደጋ ጊዜ ተጠሪ ስልክ',
emergencyContactRelation: 'የአደጋ ጊዜ ተጠሪ ዝምድና',
},
profileNudge: {
title: 'መገለጫዎን ማጠናቀቅ',
body: 'የሚያስፈልጉ፡ {{fields}}',
andMore: 'እና ሌሎች {{count}}',
action: 'መገለጫ አጠናቅቅ',
dismiss: 'አሰናብት',
},
profileGate: {
title_one: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃ እንፈልጋለን',
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
addDetails: 'እነዚህን መረጃዎች ጨምር',
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
},
profileSections: {
personal: 'የግል መረጃ',
address: 'መታወቂያ እና አድራሻ',
emergency: 'የአደጋ ጊዜ ተጠሪ',
documents: 'ሰነዶች',
completeness: '{{value}}% ተጠናቋል',
saveSection: 'ይህን ክፍል አስቀምጥ',
sectionSaved: 'ተቀምጧል',
},
profile: {
title: 'መገለጫዬ',
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',

View File

@@ -87,6 +87,56 @@ export const en = {
quickActions: 'Quick actions',
},
/** Field labels shared by the completeness nudge and the requirement gates. */
profileFields: {
firstName: 'First name',
middleName: 'Middle name',
lastName: 'Last name',
gender: 'Gender',
dob: 'Date of birth',
pob: 'Place of birth',
maritalStatus: 'Marital status',
professionId: 'Profession',
idType: 'ID type',
idNumber: 'ID number',
nationality: 'Nationality',
primaryPhoneNumber: 'Phone number',
email: 'Email address',
regionId: 'Region',
cityId: 'City',
subCityId: 'Sub-city',
woredaId: 'Woreda',
streetAddress: 'Street address',
emergencyContactName: 'Emergency contact name',
emergencyContactPhone: 'Emergency contact phone',
emergencyContactRelation: 'Emergency contact relationship',
},
profileNudge: {
title: 'Finish setting up your profile',
body: 'Still needed: {{fields}}',
andMore: 'and {{count}} more',
action: 'Complete profile',
dismiss: 'Dismiss',
},
profileGate: {
title_one: 'We need {{count}} more detail before you continue',
title_other: 'We need {{count}} more details before you continue',
addDetails: 'Add these details',
viewProfile: 'View full profile',
},
profileSections: {
personal: 'Personal',
address: 'Identity & Address',
emergency: 'Emergency Contact',
documents: 'Documents',
completeness: '{{value}}% complete',
saveSection: 'Save this section',
sectionSaved: 'Saved',
},
profile: {
title: 'My Profile',
subtitle: 'Manage your account details and preferences.',

View File

@@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import { logout } from '@ema-platform/auth';
import { BrandMark, logout } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
@@ -179,6 +179,7 @@ export function PortalLayout() {
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
brandLogo={<BrandMark size={32} />}
/>
</AppShell.Navbar>

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import { logout } from '@ema-platform/auth';
import { BrandMark, logout } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
@@ -66,6 +66,7 @@ export function VesselOwnerLayout() {
onNavigate={go}
brandName="Vessel Owner Portal"
brandSubtitle="EMAA"
brandLogo={<BrandMark size={32} />}
/>
</AppShell.Navbar>

View File

@@ -7,9 +7,6 @@ import { ProtectedRoute } from './components/ProtectedRoute';
// Auth (standalone pages, no portal chrome)
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
// Profile setup
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
import { ProfilePage } from './features/profile/pages/ProfilePage';
@@ -59,16 +56,13 @@ export const router = createBrowserRouter([
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
path: '/forgot-password',
},
{
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
path: '/profile-setup',
},
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
// profile details are collected where they are actually needed: on /profile,
// via the dashboard nudge, or inline in an application flow. The path stays
// as a redirect so existing bookmarks and emailed links do not 404.
{ path: '/profile-setup', element: <Navigate to="/profile" replace /> },
// Portal — protected.
// Profile setup is deliberately not enforced: applicants go straight to the
// portal after signing up and supply whatever a given licence type asks for
// as part of that application. `/profile-setup` stays reachable for the
// seafarer features, which do need a profile record.
{
element: (
<ProtectedRoute>

View File

@@ -1,5 +1,6 @@
import { FlatCompat } from '@eslint/eslintrc';
import nxEslintPlugin from '@nx/eslint-plugin';
import reactHooks from 'eslint-plugin-react-hooks';
import js from '@eslint/js';
const compat = new FlatCompat({
@@ -24,6 +25,18 @@ export default [
],
},
},
// Source files carry `eslint-disable-next-line react-hooks/exhaustive-deps`
// comments, but the plugin was never registered — so every one of them was
// an error ("Definition for rule ... was not found") rather than a
// suppression, and no hook was ever actually checked.
{
files: ['**/*.tsx', '**/*.jsx'],
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
...compat.config({
extends: ['plugin:@nx/typescript'],
}).map((c) => ({ ...c, files: ['**/*.ts', '**/*.tsx'] })),

View File

@@ -14,10 +14,44 @@ import type {
LicenseStatus,
LicenseType,
LicenseTypeRequirements,
AssignableOfficer,
DocumentDecision,
DocumentReview,
ExportResult,
LicenseTemplate,
Paginated,
QueueCounts,
QueueFilter,
RemarkTargetType,
SavedQueueView,
TemplatePageOptions,
TemplateVariable,
} from './licensing.types';
/**
* Drops empty filters and flattens the status array.
*
* The grid keeps every facet in one object and serialises it to the URL, so
* cleared facets arrive as undefined/[]; sending those verbatim would put
* `status=` and `assignee=` on the wire and fail DTO validation.
*/
function serialiseQueueFilter(
filter: QueueFilter | void,
): Record<string, unknown> {
if (!filter) return {};
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
params[key] = value.join(',');
continue;
}
params[key] = value;
}
return params;
}
const TAGS = [
'LicenseType',
'LicenseApplication',
@@ -26,6 +60,8 @@ const TAGS = [
'Notification',
'Inspection',
'License',
'SavedView',
'LicenseTemplate',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -86,6 +122,20 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/** Validity is edited beside the certificate design, not with the fees. */
updateLicenseValidity: builder.mutation<
LicenseType,
{ id: string; validityMonths: number }
>({
query: ({ id, validityMonths }) => ({
url: `/license-types/${id}/validity`,
method: 'PATCH',
body: { validityMonths },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
}),
getLicenseTypeRequirements: builder.query<
LicenseTypeRequirements,
{ idOrKey: string; kind?: ApplicationKind }
@@ -234,30 +284,82 @@ export const licensingApi = baseApi
providesTags: () => [listTag('License')],
}),
suspendLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/licenses/${id}/suspend`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('License', id), listTag('License')],
}),
revokeLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/licenses/${id}/revoke`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('License', id), listTag('License')],
}),
reinstateLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
query: ({ id, reason }) => ({
url: `/licenses/${id}/reinstate`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('License', id), listTag('License')],
}),
getCertificateUrl: builder.mutation<{ url: string }, string>({
query: (id) => ({ url: `/licenses/${id}/certificate` }),
}),
// ------------------------------------------------------------- review
getQueue: builder.query<
Paginated<LicenseApplication>,
{ licenseTypeId?: string; search?: string } | void
>({
query: (params) => ({ url: '/license-application-review/queue', params: params ?? {} }),
getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({
query: (params) => ({
url: '/license-application-review/queue',
params: serialiseQueueFilter(params),
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
getAssignedToMe: builder.query<
Paginated<LicenseApplication>,
{ licenseTypeId?: string; search?: string } | void
QueueFilter | void
>({
query: (params) => ({
url: '/license-application-review/assigned-to-me',
params: params ?? {},
params: serialiseQueueFilter(params),
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
/** Every non-draft application — backs the grid's "All" view. */
getAllApplications: builder.query<
Paginated<LicenseApplication>,
QueueFilter | void
>({
query: (params) => ({
url: '/license-application-review/all',
params: serialiseQueueFilter(params),
}),
providesTags: () => [listTag('ApplicationQueue')],
}),
/**
* Counts for the saved-view tabs. Shares the ApplicationQueue tag, so a
* claim or a decision refreshes the badges along with the list and the
* numbers can never drift from what the grid is showing.
*/
getQueueCounts: builder.query<QueueCounts, void>({
query: () => ({ url: '/license-application-review/counts' }),
providesTags: () => [listTag('ApplicationQueue')],
}),
getApplicationForReview: builder.query<ApplicationDetail, string>({
query: (id) => ({ url: `/license-application-review/${id}` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
@@ -288,6 +390,8 @@ export const licensingApi = baseApi
id: string;
generalRemark?: string;
items: { targetType: RemarkTargetType; targetKey: string; remark: string }[];
/** Officer-edited wording for the applicant notification. */
notificationBody?: string;
}
>({
query: ({ id, ...body }) => ({
@@ -327,16 +431,213 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
rejectApplication: builder.mutation<LicenseApplication, { id: string; reason: string }>({
query: ({ id, reason }) => ({
rejectApplication: builder.mutation<
LicenseApplication,
{ id: string; reason: string; notificationBody?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/reject`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// ------------------------------------------------- certificate designs
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
query: (licenseTypeId) => ({
url: '/license-templates',
params: licenseTypeId ? { licenseTypeId } : {},
}),
providesTags: () => [listTag('LicenseTemplate')],
}),
getTemplateVariables: builder.query<TemplateVariable[], void>({
query: () => ({ url: '/license-templates/variables' }),
}),
getBuiltInTemplate: builder.query<{ hbsSource: string }, void>({
query: () => ({ url: '/license-templates/built-in' }),
}),
createLicenseTemplate: builder.mutation<
LicenseTemplate,
{
licenseTypeId: string;
name: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
}
>({
query: (body) => ({ url: '/license-templates', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
updateLicenseTemplate: builder.mutation<
LicenseTemplate,
{
id: string;
name?: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
}
>({
query: ({ id, ...body }) => ({
url: `/license-templates/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
publishLicenseTemplate: builder.mutation<LicenseTemplate, string>({
query: (id) => ({ url: `/license-templates/${id}/publish`, method: 'POST' }),
// The previously published design is archived by the same call, so the
// whole list is refetched rather than patching two rows by hand.
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
archiveLicenseTemplate: builder.mutation<LicenseTemplate, string>({
query: (id) => ({ url: `/license-templates/${id}/archive`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
deleteLicenseTemplate: builder.mutation<unknown, string>({
query: (id) => ({ url: `/license-templates/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
}),
// ------------------------------------------------------ document review
getDocumentReviews: builder.query<DocumentReview[], string>({
query: (id) => ({ url: `/license-application-review/${id}/document-reviews` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
reviewDocument: builder.mutation<
DocumentReview,
{
id: string;
documentKey: string;
decision: DocumentDecision;
reason?: string;
attachmentId?: string;
}
>({
query: ({ id, documentKey, ...body }) => ({
url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
clearDocumentReview: builder.mutation<unknown, { id: string; documentKey: string }>({
query: ({ id, documentKey }) => ({
url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`,
method: 'DELETE',
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id)],
}),
// ----------------------------------------------------------- saved views
getSavedViews: builder.query<SavedQueueView[], void>({
query: () => ({ url: '/license-application-review/saved-views' }),
providesTags: () => [listTag('SavedView')],
}),
createSavedView: builder.mutation<
SavedQueueView,
{ name: string; queryString: string; isShared?: boolean; isDefault?: boolean }
>({
query: (body) => ({
url: '/license-application-review/saved-views',
method: 'POST',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]),
}),
deleteSavedView: builder.mutation<unknown, string>({
query: (viewId) => ({
url: `/license-application-review/saved-views/${viewId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]),
}),
// ------------------------------------------------------------- export
/**
* The whole filtered result set, not the current page. Lazy so it only
* runs when the officer actually asks for a file.
*/
exportApplications: builder.query<ExportResult, QueueFilter | void>({
query: (params) => ({
url: '/license-application-review/export',
params: serialiseQueueFilter(params),
}),
}),
/** Officers the Assign and Escalate dialogs can offer. */
getAssignableOfficers: builder.query<AssignableOfficer[], void>({
query: () => ({ url: '/license-application-review/officers' }),
}),
// ----------------------------------------------------- workflow controls
assignApplication: builder.mutation<
LicenseApplication,
{ id: string; officerId: string; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/assign`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
holdApplication: builder.mutation<
LicenseApplication,
{ id: string; reason: string }
>({
query: ({ id, reason }) => ({
url: `/license-application-review/${id}/hold`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
resumeApplication: builder.mutation<
LicenseApplication,
{ id: string; remark?: string }
>({
query: ({ id, remark }) => ({
url: `/license-application-review/${id}/resume`,
method: 'POST',
body: { remark },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
escalateApplication: builder.mutation<
LicenseApplication,
{ id: string; supervisorId: string; reason: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/escalate`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
confirmPayment: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-application-review/${id}/confirm-payment`,
@@ -397,6 +698,7 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useGetMyApplicationsQuery,
@@ -417,6 +719,31 @@ export const {
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,
useGetAllApplicationsQuery,
useGetQueueCountsQuery,
useGetLicenseTemplatesQuery,
useGetTemplateVariablesQuery,
useGetBuiltInTemplateQuery,
useCreateLicenseTemplateMutation,
useUpdateLicenseTemplateMutation,
usePublishLicenseTemplateMutation,
useArchiveLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetDocumentReviewsQuery,
useReviewDocumentMutation,
useClearDocumentReviewMutation,
useGetSavedViewsQuery,
useCreateSavedViewMutation,
useDeleteSavedViewMutation,
useLazyExportApplicationsQuery,
useGetAssignableOfficersQuery,
useSuspendLicenseMutation,
useRevokeLicenseMutation,
useReinstateLicenseMutation,
useAssignApplicationMutation,
useHoldApplicationMutation,
useResumeApplicationMutation,
useEscalateApplicationMutation,
useGetApplicationForReviewQuery,
useClaimApplicationMutation,
useCompleteReviewMutation,

View File

@@ -57,6 +57,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
INSPECTION_COMPLETED: 'Inspection Completed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing Certificate',
@@ -75,6 +76,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
INSPECTION_COMPLETED: 'cyan',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'gray',
PAYMENT_PENDING: 'yellow',
PAID: 'lime',
PAYMENT_CONFIRMED: 'teal',
@@ -97,6 +99,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
INSPECTION_PENDING: 55,
INSPECTION_COMPLETED: 65,
APPROVED: 75,
// Parked, so it keeps the progress of wherever it was held from.
ON_HOLD: 45,
PAYMENT_PENDING: 80,
PAID: 88,
PAYMENT_CONFIRMED: 94,

View File

@@ -16,6 +16,7 @@ export type LicenseStatus =
| 'INSPECTION_COMPLETED'
| 'APPROVED'
| 'REJECTED'
| 'ON_HOLD'
| 'PAYMENT_PENDING'
| 'PAID'
| 'PAYMENT_CONFIRMED'
@@ -97,6 +98,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.
*/
slaHours: number | null;
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
@@ -202,6 +208,8 @@ export interface Attachment {
validFrom: string | null;
validTo: string | null;
files: AttachmentFile[];
/** From BaseEntity. Used to place the upload on the activity trail. */
createdAt?: string;
}
export interface StatusHistoryEntry {
@@ -264,6 +272,105 @@ export interface AppNotification {
createdAt: string;
}
/** Server-side queue filters. Mirrors ApplicationQueueFilterDto in the API. */
export interface QueueFilter {
licenseTypeId?: string;
search?: string;
status?: LicenseStatus[];
/** Officer uuid, or the literal 'unassigned'. */
assignee?: string;
submittedFrom?: string;
submittedTo?: string;
overdue?: boolean;
sortBy?: QueueSortField;
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
}
export type QueueSortField =
| 'submittedAt'
| 'applicationNumber'
| 'companyName'
| 'status'
| 'dueAt';
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
unassigned: number;
mine: number;
awaitingApplicant: number;
overdue: number;
readyToIssue: number;
all: number;
}
export type DocumentDecision = 'ACCEPTED' | 'REJECTED';
/** An officer's verdict on one uploaded document. */
export interface DocumentReview {
id: string;
applicationId: string;
documentKey: string;
attachmentId: string | null;
decision: DocumentDecision;
reason: string | null;
reviewedById: string | null;
reviewedByName: string | null;
updatedAt: string;
}
/** An officer-defined queue filter, persisted server-side. */
export interface SavedQueueView {
id: string;
ownerUserId: string;
name: string;
queryString: string;
isShared: boolean;
isDefault: boolean;
sortOrder: number;
}
export interface AssignableOfficer {
id: string;
name: string | null;
}
/** Full filtered result set for export. `truncated` when the cap was hit. */
export interface ExportResult {
items: LicenseApplication[];
total: number;
truncated: boolean;
}
export type TemplateStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
export interface TemplatePageOptions {
format?: 'A4' | 'A5' | 'Letter' | 'Legal';
landscape?: boolean;
printBackground?: boolean;
}
/** A certificate design authored in the backoffice. */
export interface LicenseTemplate {
id: string;
licenseTypeId: string;
name: string;
version: number;
hbsSource: string;
pageOptions: TemplatePageOptions | null;
status: TemplateStatus;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
/** A placeholder a certificate design may use. */
export interface TemplateVariable {
key: string;
label: string;
}
export interface Paginated<T> {
total: number;
items: T[];
@@ -321,6 +428,10 @@ export interface ApplicationPayment {
/** A licence certificate issued to the applicant. */
export interface IssuedLicense {
/** Why the licence was suspended, revoked or cancelled. */
statusReason?: string | null;
statusChangedAt?: string | null;
statusChangedByName?: string | null;
id: string;
certificateNumber: string;
licenseTypeId: string;

View File

@@ -9,6 +9,17 @@ export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { usePermissions } from './lib/hooks/usePermissions';
export type { PermissionSet } from './lib/hooks/usePermissions';
export {
useCurrentProfile,
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
PROFILE_FIELDS,
PROFILE_FIELD_SECTION,
} from './lib/hooks/useCurrentProfile';
export type { ProfileField, ProfileRequirement, ProfileMeResponse } from './lib/hooks/useCurrentProfile';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export { refreshAccessToken } from './lib/utils/refresh-token';
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';

View File

@@ -0,0 +1,164 @@
import { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { baseApi } from '@ema-platform/api';
import { authStorage } from '../utils/auth-storage';
import { setCurrentProfile } from '../store/auth.slice';
import type { CurrentProfile } from '../types/auth.types';
/**
* Profile fields the portal knows how to collect.
*
* Mirrors `PROFILE_FIELDS` in the API (`module/profile/profile-completeness.ts`).
* Screens name the fields they need from this list rather than checking
* properties ad hoc, so "what is missing" has one definition.
*/
export const PROFILE_FIELDS = [
'firstName',
'middleName',
'lastName',
'gender',
'dob',
'pob',
'maritalStatus',
'professionId',
'idType',
'idNumber',
'nationality',
'primaryPhoneNumber',
'email',
'regionId',
'cityId',
'subCityId',
'woredaId',
'streetAddress',
'emergencyContactName',
'emergencyContactPhone',
'emergencyContactRelation',
] as const;
export type ProfileField = (typeof PROFILE_FIELDS)[number];
/** Which `/profile` tab collects a field — used to build deep links. */
export const PROFILE_FIELD_SECTION: Record<ProfileField, 'personal' | 'address' | 'emergency'> = {
firstName: 'personal',
middleName: 'personal',
lastName: 'personal',
gender: 'personal',
dob: 'personal',
pob: 'personal',
maritalStatus: 'personal',
professionId: 'personal',
idType: 'address',
idNumber: 'address',
nationality: 'address',
primaryPhoneNumber: 'address',
email: 'address',
regionId: 'address',
cityId: 'address',
subCityId: 'address',
woredaId: 'address',
streetAddress: 'address',
emergencyContactName: 'emergency',
emergencyContactPhone: 'emergency',
emergencyContactRelation: 'emergency',
};
/** What a screen needs before it can do its job. */
export interface ProfileRequirement {
fields: ProfileField[];
/** Shown to the applicant — why this is being asked for, in plain language. */
reason: string;
}
export interface ProfileMeResponse {
profile: CurrentProfile;
completeness: number;
missing: ProfileField[];
}
const profileApi = baseApi
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMyProfile: builder.query<ProfileMeResponse, void>({
query: () => ({ url: '/profiles/me' }),
providesTags: ['CurrentProfile'],
}),
/**
* Saves one tab of `/profile`. Invalidates the profile so the
* completeness meter and every requirement gate recompute at once.
*/
updateMyProfile: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
updateMyAddress: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/addresses/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
}),
overrideExisting: false,
});
export const {
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
} = profileApi;
export const currentProfileApi = profileApi;
/**
* The one way to get at the signed-in user's profile.
*
* Replaces `authStorage.getProfileId()`, which only ever held a value if the
* user had been through the (now deleted) setup wizard. Pages that read it
* directly did `if (!profileId) return;` and rendered blank forever for anyone
* who signed up afterwards.
*
* Resolution order: RTK Query cache → `authStorage` (so the id is available
* synchronously on the very first render) → `GET /profiles/me`, which
* provisions a profile if the user has none. The resolved id is written back
* to storage. Never blocks render: `profileId` may be undefined for a tick,
* and callers should show a loading or empty state rather than bail out.
*/
export function useCurrentProfile() {
const dispatch = useDispatch();
const { data, isLoading, isFetching, error, refetch } = useGetMyProfileQuery();
const profileId = data?.profile?.id ?? authStorage.getProfileId();
useEffect(() => {
if (!data?.profile) return;
authStorage.setProfileId(data.profile.id);
dispatch(setCurrentProfile(data.profile));
}, [data?.profile, dispatch]);
const missing = useMemo(() => data?.missing ?? [], [data?.missing]);
return {
profileId,
profile: data?.profile,
isLoading,
isFetching,
error,
refetch,
completeness: data?.completeness ?? 0,
missing,
/**
* True when nothing the requirement asks for is still blank. Unknown
* profile (still loading) reads as not-ready, so a caller never submits
* against data it has not seen.
*/
isReadyFor: (requirement: ProfileRequirement) =>
Boolean(data) && requirement.fields.every((field) => !missing.includes(field)),
/** The subset of a requirement that is still outstanding. */
gapsFor: (requirement: ProfileRequirement) =>
requirement.fields.filter((field) => missing.includes(field)),
};
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { authStorage } from '../utils/auth-storage';
interface TokenClaims {
permissions?: string[];
roles?: string[];
}
/**
* Reads the claims out of the access token without verifying it.
*
* Verification is the API's job — this is only used to decide what to *show*.
* Every guarded route is enforced server-side by `PermissionGuard`, so the
* worst a wrong answer here can do is offer a menu item that then 403s.
*/
function decodeClaims(token: string | undefined): TokenClaims | null {
if (!token) return null;
const payload = token.split('.')[1];
if (!payload) return null;
try {
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
// The claim set is UTF-8; atob yields latin-1, so non-ASCII names would
// otherwise come back mangled.
const decoded = decodeURIComponent(
json
.split('')
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join(''),
);
return JSON.parse(decoded) as TokenClaims;
} catch {
return null;
}
}
export interface PermissionSet {
permissions: string[];
/** True if the user holds any one of `required`. Empty `required` = allowed. */
can: (required?: string[]) => boolean;
/**
* Whether permissions could be read at all. When false, callers should show
* everything rather than hide the whole application from someone whose token
* simply does not carry the claim.
*/
known: boolean;
}
/**
* What the signed-in user is allowed to do.
*
* Deliberately fails open: if the token carries no `permissions` claim we
* report `known: false` and `can()` returns true. Hiding navigation on a
* claim-shape mismatch would leave a legitimate officer staring at an empty
* sidebar with no way to tell why, whereas failing open costs at most a 403
* on a link they should not have seen.
*/
export function usePermissions(): PermissionSet {
// Re-read whenever the session changes rather than only on mount.
const token = useSelector(
(state: { auth?: { token?: string | null } }) => state.auth?.token,
);
return useMemo(() => {
const claims = decodeClaims(token ?? authStorage.getToken());
const permissions = claims?.permissions ?? [];
const known = Array.isArray(claims?.permissions);
const granted = new Set(permissions);
return {
permissions,
known,
can: (required?: string[]) => {
if (!required?.length) return true;
if (!known) return true;
return required.some((permission) => granted.has(permission));
},
};
}, [token]);
}

View File

@@ -48,7 +48,7 @@ export function LoginPage() {
const [serverError, setServerError] = useState<string | null>(null);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const {
register,
@@ -74,22 +74,22 @@ export function LoginPage() {
}).unwrap();
dispatch(setUser(me));
// Load the seafarer profile if one exists, so those screens have it —
// but never gate sign-in on it.
// Warm the profile so screens that need an id have one on first paint.
// `/profiles/me` provisions an empty profile when the user has none, so
// unlike the old filtered lookup this cannot come back empty-handed.
// Sign-in is still never gated on it — a failure here is ignored and
// `useCurrentProfile` resolves it again on demand.
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
url: `/profiles?q=${encodeURIComponent(q)}`,
const { profile } = await profileTrigger({
url: '/profiles/me',
method: 'GET',
}).unwrap();
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
if (profile) {
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
}
} catch {
// No profile yet. That is fine — a profile is only needed by the
// seafarer features, not to apply for a licence.
// Offline or a 5xx — the portal still works; the resolver retries.
}
if (!me.isPhoneNumberVerified) {

View File

@@ -3,8 +3,12 @@ export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/feedback/FeatureUnavailable';
export * from './lib/feedback/EmptyState';
export * from './lib/feedback/ErrorState';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/AppTopNav';
export * from './lib/layout/nav-utils';
export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';

View File

@@ -0,0 +1,47 @@
import { Button, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconInbox, type Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface EmptyStateProps {
title: string;
description?: string;
icon?: Icon;
action?: { label: string; onClick: () => void; icon?: ReactNode };
}
/**
* The "nothing here" state.
*
* Distinct from an error: an empty queue is a normal, often good, outcome. It
* says so plainly and offers the next useful action rather than leaving a bare
* grey panel that reads as a failure.
*/
export function EmptyState({
title,
description,
icon: StateIcon = IconInbox,
action,
}: EmptyStateProps) {
return (
<Paper p="xl" withBorder>
<Stack align="center" gap="sm" py="xl">
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
<StateIcon size={28} stroke={1.5} />
</ThemeIcon>
<Text fw={600} fz="lg" ta="center">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={440}>
{description}
</Text>
)}
{action && (
<Button mt="xs" variant="light" leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Button>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,60 @@
import { Button, Code, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconAlertTriangle, IconRefresh, type Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
title: string;
/** What actually failed, in the API's words where available. */
description?: string;
/**
* Request/trace id. Shown verbatim and selectable so a user can quote it in
* a support ticket — "it didn't work" is not something anyone can act on.
*/
correlationId?: string;
onRetry?: () => void;
icon?: Icon;
}
/** The error state every screen shows: what broke, how to retry, what to quote. */
export function ErrorState({
title,
description,
correlationId,
onRetry,
icon: CustomIcon = IconAlertTriangle,
}: ErrorStateProps) {
const { t } = useTranslation();
return (
<Paper p="xl" withBorder role="alert">
<Stack align="center" gap="sm" py="lg">
<ThemeIcon size={48} radius="xl" color="red" variant="light">
<CustomIcon size={26} />
</ThemeIcon>
<Text fw={600} fz="lg" ta="center">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={460}>
{description}
</Text>
)}
{correlationId && (
<Text size="xs" c="dimmed">
{t('error.reference', 'Reference')}: <Code>{correlationId}</Code>
</Text>
)}
{onRetry && (
<Button
mt="xs"
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={onRetry}
>
{t('error.retry', 'Try again')}
</Button>
)}
</Stack>
</Paper>
);
}

View File

@@ -53,8 +53,10 @@ export function AppHeader({
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{/* Hamburger — styled like user-management Top.tsx */}
{/* The Burger itself owns the click so the control is a real, keyboard
reachable <button>; the Box is chrome only. It previously wrapped a
no-op button, which no keyboard user could operate. */}
<Box
onClick={isMobile ? onToggleNav : onToggleSidebar}
style={{
display: 'flex',
alignItems: 'center',
@@ -84,7 +86,7 @@ export function AppHeader({
>
<Burger
opened={navOpened}
onClick={() => {}}
onClick={isMobile ? onToggleNav : onToggleSidebar}
size="sm"
aria-label="Toggle navigation"
styles={{

View File

@@ -2,6 +2,7 @@ import {
AppShell,
Badge,
NavLink,
Popover,
ScrollArea,
Stack,
Text,
@@ -14,13 +15,32 @@ import {
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { BrandMark } from '@ema-platform/auth';
import {
badgeLabel,
isBranchActive,
isItemActive,
toSections,
type NavEntries,
} from './nav-utils';
export interface NavItem {
label: string;
icon: Icon;
to?: string;
/**
* One level only. A parent with children is a disclosure, not a destination,
* so give it either `to` or `children` — not both.
*/
children?: NavItem[];
/**
* Pending count, or `'dot'` for "something is waiting" without a number.
* Zero renders nothing: a badge reading 0 is worse than no badge.
*/
badge?: number | 'dot';
/** Hidden unless the user holds at least one of these. */
permissions?: string[];
/** Not yet connected to real data — surfaced as a "Soon" badge. */
soon?: boolean;
}
@@ -32,15 +52,174 @@ export interface NavSection {
items: NavItem[];
}
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
export type { NavEntries } from './nav-utils';
function toSections(entries: NavEntries): NavSection[] {
if (entries.length === 0) return [];
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
return isSectioned
? (entries as NavSection[])
: [{ items: entries as NavItem[] }];
/** Shown when an item is disabled, so no control is ever dead without a reason. */
function itemTooltip(item: NavItem, soonLabel: string): string | undefined {
return item.soon ? soonLabel : undefined;
}
interface SidebarItemProps {
item: NavItem;
collapsed: boolean;
activePath: string;
onNavigate: (item: NavItem) => void;
}
/**
* One nav entry, at either level.
*
* A parent with children renders as a Mantine NavLink disclosure that starts
* open when the current route is inside it, so the user can always see where
* they are without hunting. Collapsed to the icon rail there is no room to
* nest, so a parent becomes a hover flyout instead of silently losing its
* children.
*/
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
const { t } = useTranslation();
const ItemIcon = item.icon;
const hasChildren = Boolean(item.children?.length);
const active = isItemActive(item, activePath);
const branchActive = isBranchActive(item, activePath);
const soonLabel = `${t(item.label)}${t('nav.soon', 'Soon')}`;
const badge = badgeLabel(item.badge);
const badgeNode = badge !== null && (
<Badge
size="xs"
circle={item.badge === 'dot'}
variant="filled"
color="red"
radius="sm"
aria-label={
item.badge === 'dot'
? t('nav.pending', 'Items pending')
: t('nav.pendingCount', { count: Number(item.badge), defaultValue: '{{count}} pending' })
}
>
{badge}
</Badge>
);
const rightSection = item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeNode || undefined
);
if (collapsed) {
const trigger = (
<UnstyledButton
onClick={() => !hasChildren && onNavigate(item)}
aria-label={t(item.label)}
aria-current={active ? 'page' : undefined}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
{badge !== null && (
<Badge
size="xs"
circle
variant="filled"
color="red"
style={{ position: 'absolute', top: rem(6), right: rem(10) }}
/>
)}
</UnstyledButton>
);
// Children would be unreachable behind an icon, so open them in a flyout.
if (hasChildren) {
return (
<Popover position="right-start" withArrow shadow="md" trapFocus>
<Popover.Target>{trigger}</Popover.Target>
<Popover.Dropdown p={4}>
<Text size="xs" fw={700} c="dimmed" px="xs" py={4}>
{t(item.label)}
</Text>
{item.children?.map((child) => (
<NavLink
key={child.label}
active={isItemActive(child, activePath)}
label={t(child.label)}
leftSection={<child.icon size={17} stroke={1.6} />}
onClick={() => onNavigate(child)}
variant="light"
styles={{ root: { borderRadius: rem(8) } }}
/>
))}
</Popover.Dropdown>
</Popover>
);
}
return (
<Tooltip
label={itemTooltip(item, soonLabel) ?? t(item.label)}
position="right"
withArrow
>
{trigger}
</Tooltip>
);
}
return (
<NavLink
active={hasChildren ? branchActive && !active : active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
rightSection={rightSection}
// Auto-expands the group the user is currently inside.
defaultOpened={hasChildren ? branchActive : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
>
{hasChildren
? item.children?.map((child) => (
<NavLink
key={child.label}
active={isItemActive(child, activePath)}
label={t(child.label)}
leftSection={<child.icon size={17} stroke={1.6} />}
rightSection={
child.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeLabel(child.badge) !== null && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badgeLabel(child.badge)}
</Badge>
)
) || undefined
}
onClick={() => onNavigate(child)}
variant="light"
styles={{ root: { borderRadius: rem(8) }, label: { fontWeight: 500 } }}
/>
))
: null}
</NavLink>
);
}
interface AppSidebarProps {
@@ -51,6 +230,12 @@ interface AppSidebarProps {
onNavigate: (item: NavItem) => void;
brandName: string;
brandSubtitle: string;
/**
* Rendered in the brand header. Passed in rather than imported so this
* library stays free of a dependency on `@ema-platform/auth`, which depends
* on it — the two formed an import cycle.
*/
brandLogo?: ReactNode;
}
export function AppSidebar({
@@ -61,13 +246,10 @@ export function AppSidebar({
onNavigate,
brandName,
brandSubtitle,
brandLogo,
}: AppSidebarProps) {
const { t } = useTranslation();
const activeNavItem = (item: NavItem) =>
!!item.to &&
(activePath === item.to || activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
@@ -83,7 +265,7 @@ export function AppSidebar({
flexShrink: 0,
}}
>
<BrandMark size={32} />
{brandLogo}
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
@@ -141,61 +323,15 @@ export function AppSidebar({
}}
/>
)}
{section.items.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip
{section.items.map((item) => (
<SidebarItem
key={item.label}
label={item.soon ? `${t(item.label)}${t('nav.soon', 'Soon')}` : t(item.label)}
position="right"
withArrow
>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: active ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
</UnstyledButton>
</Tooltip>
);
}
return (
<NavLink
key={item.label}
active={active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
// Tells a reviewer at a glance which screens are wired up.
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : undefined
}
onClick={() => onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
/>
);
})}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
/>
))}
</Stack>
))}
</Stack>

View File

@@ -0,0 +1,187 @@
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { NavItem } from './AppSidebar';
import {
badgeLabel,
isBranchActive,
isItemActive,
toSections,
type NavEntries,
} from './nav-utils';
interface AppTopNavProps {
navItems: NavEntries;
activePath: string;
onNavigate: (item: NavItem) => void;
}
/**
* Horizontal navigation for the top-bar layout.
*
* Every destination used to render as a sibling button in one horizontally
* scrolling strip, so with twenty-odd of them most were off-screen and the
* grouping that the sidebar already had was thrown away. Here each section
* collapses to a single labelled dropdown, which fits and keeps the same
* information architecture as the sidebar.
*/
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
const { t } = useTranslation();
const sections = toSections(navItems);
return (
<Group
gap={rem(2)}
h="100%"
wrap="nowrap"
role="navigation"
aria-label={t('nav.primary', 'Primary')}
>
{sections.map((section, index) => {
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
if (!section.label) {
return section.items.map((item) => (
<TopNavButton
key={item.label}
label={t(item.label)}
active={isBranchActive(item, activePath)}
badge={badgeLabel(item.badge)}
soon={item.soon}
onClick={() => onNavigate(item)}
/>
));
}
const sectionActive = section.items.some((item) =>
isBranchActive(item, activePath),
);
const pending = section.items.reduce((sum, item) => {
const own = typeof item.badge === 'number' ? item.badge : 0;
const nested = (item.children ?? []).reduce(
(n, child) => n + (typeof child.badge === 'number' ? child.badge : 0),
0,
);
return sum + own + nested;
}, 0);
return (
<Menu
key={section.label ?? `section-${index}`}
trigger="click-hover"
openDelay={80}
closeDelay={140}
position="bottom-start"
withinPortal
shadow="md"
>
<Menu.Target>
<TopNavButton
label={t(section.label)}
active={sectionActive}
badge={badgeLabel(pending)}
withChevron
/>
</Menu.Target>
<Menu.Dropdown>
{section.items.map((item) =>
item.children?.length ? (
<Menu.Sub key={item.label} position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<item.icon size={16} stroke={1.6} />}>
{t(item.label)}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{item.children.map((child) => (
<Menu.Item
key={child.label}
leftSection={<child.icon size={16} stroke={1.6} />}
onClick={() => onNavigate(child)}
disabled={child.soon}
>
{t(child.label)}
</Menu.Item>
))}
</Menu.Sub.Dropdown>
</Menu.Sub>
) : (
<Menu.Item
key={item.label}
leftSection={<item.icon size={16} stroke={1.6} />}
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeLabel(item.badge) !== null && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badgeLabel(item.badge)}
</Badge>
)
) || undefined
}
onClick={() => onNavigate(item)}
// `soon` screens have no backend; the Menu.Item's own
// disabled styling plus the badge explains why.
disabled={item.soon}
aria-current={isItemActive(item, activePath) ? 'page' : undefined}
>
{t(item.label)}
</Menu.Item>
),
)}
</Menu.Dropdown>
</Menu>
);
})}
</Group>
);
}
interface TopNavButtonProps {
label: string;
active: boolean;
badge?: string | null;
soon?: boolean;
withChevron?: boolean;
onClick?: () => void;
}
function TopNavButton({
label,
active,
badge,
soon,
withChevron,
onClick,
}: TopNavButtonProps) {
return (
<UnstyledButton
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: `0 ${rem(14)}`,
height: '100%',
borderBottom: '2px solid',
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
opacity: soon ? 0.55 : 1,
marginBottom: -1,
}}
>
<span>{label}</span>
{badge !== null && badge !== undefined && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badge}
</Badge>
)}
{withChevron && <IconChevronDown size={14} stroke={2} />}
</UnstyledButton>
);
}

View File

@@ -0,0 +1,79 @@
import type { NavItem, NavSection } from './AppSidebar';
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
export function toSections(entries: NavEntries): NavSection[] {
if (entries.length === 0) return [];
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
return isSectioned
? (entries as NavSection[])
: [{ items: entries as NavItem[] }];
}
/**
* Whether a nav entry points at the current route.
*
* Prefix-matches on a path boundary so `/licensing` lights up for
* `/licensing/FREIGHT_FORWARDER` but not for an unrelated `/licensing-report`.
*/
export function isItemActive(item: NavItem, activePath: string): boolean {
if (!item.to) return false;
return activePath === item.to || activePath.startsWith(`${item.to}/`);
}
/** True when the item, or anything nested under it, matches the route. */
export function isBranchActive(item: NavItem, activePath: string): boolean {
if (isItemActive(item, activePath)) return true;
return (item.children ?? []).some((child) => isItemActive(child, activePath));
}
/**
* Removes entries the user may not see.
*
* An item with no `permissions` is visible to everyone. A parent survives if
* it is itself permitted and at least one child is — a disclosure that opens
* onto nothing is worse than no disclosure at all. Sections left empty are
* dropped so their heading does not hang over a gap.
*/
export function filterByPermissions(
sections: NavSection[],
granted: readonly string[],
): NavSection[] {
const permitted = new Set(granted);
const allows = (item: NavItem) =>
!item.permissions?.length ||
item.permissions.some((permission) => permitted.has(permission));
return sections
.map((section) => ({
...section,
items: section.items
.filter(allows)
.map((item) =>
item.children
? { ...item, children: item.children.filter(allows) }
: item,
)
.filter((item) => !item.children || item.children.length > 0),
}))
.filter((section) => section.items.length > 0);
}
/** Flattens parents and children into one list, for search and breadcrumbs. */
export function flattenNav(sections: NavSection[]): NavItem[] {
return sections.flatMap((section) =>
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
);
}
/** Badge text, or null when there is nothing worth showing. */
export function badgeLabel(badge: NavItem['badge']): string | null {
if (badge === 'dot') return '';
if (typeof badge === 'number' && badge > 0) {
// Three digits of pending work is already "a lot"; the exact number
// stops being actionable and starts breaking the layout.
return badge > 99 ? '99+' : String(badge);
}
return null;
}

87
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@mantine/form": "^8.3.16",
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.16",
"@mantine/spotlight": "^8.3.18",
"@reduxjs/toolkit": "^2.11.2",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.99.0",
@@ -50,6 +51,7 @@
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^5.2.0",
"nx": "^22.5.4",
"postcss": "^8.5.1",
"prettier": "^3.6.2",
@@ -3178,6 +3180,21 @@
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/spotlight": {
"version": "8.3.18",
"resolved": "https://registry.npmjs.org/@mantine/spotlight/-/spotlight-8.3.18.tgz",
"integrity": "sha512-yFoEYG0wKduxbnv6+1CUOXc91lmQ5DN4QvEShYO2ftDm0kXhxeOvJFtGOBYK80tpmSCsaT253p9E3J3DcaOt2w==",
"license": "MIT",
"dependencies": {
"@mantine/store": "8.3.18"
},
"peerDependencies": {
"@mantine/core": "8.3.18",
"@mantine/hooks": "8.3.18",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/store": {
"version": "8.3.18",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz",
@@ -3867,6 +3884,18 @@
}
}
},
"node_modules/@mui/x-date-pickers/node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@mui/x-date-pickers/node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
@@ -3991,9 +4020,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4014,9 +4040,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4037,9 +4060,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4060,9 +4080,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4083,9 +4100,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8327,9 +8341,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8346,9 +8357,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8365,9 +8373,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8384,9 +8389,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -11949,7 +11951,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.2"
@@ -12261,6 +12263,19 @@
}
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@@ -13740,7 +13755,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -14558,9 +14573,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -14581,9 +14593,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -14604,9 +14613,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -14627,9 +14633,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -17919,7 +17922,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/sax": {

View File

@@ -22,6 +22,7 @@
"@mantine/form": "^8.3.16",
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.16",
"@mantine/spotlight": "^8.3.18",
"@reduxjs/toolkit": "^2.11.2",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.99.0",
@@ -55,6 +56,7 @@
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^5.2.0",
"nx": "^22.5.4",
"postcss": "^8.5.1",
"prettier": "^3.6.2",