Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
nati
2026-09-08 08:29:16 +00:00
32 changed files with 7789 additions and 422 deletions

View File

@@ -0,0 +1,110 @@
import type { ReactNode } from 'react';
import { Box, Card, Center, Group, Text, ThemeIcon } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { ResponsiveContainer } from 'recharts';
/**
* The shared chrome every backoffice chart sits in.
*
* Lifted out of the operations dashboard when the logistics head's dashboard
* needed the same frame: two copies would have meant two chart heights, two
* axis colours and two tooltip styles, which is precisely how a product ends
* up looking assembled rather than designed. The tokens are exported alongside
* it because a chart's axes and tooltip live inside the Recharts tree, not in
* this wrapper.
*/
export const CHART_HEIGHT = 280;
export const AXIS_STYLE = {
fontSize: 11,
fill: 'var(--mantine-color-dimmed)',
} as const;
export const GRID_COLOR = 'var(--mantine-color-default-border)';
export const TOOLTIP_BOX_STYLE = {
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
fontSize: 12,
} as const;
export interface ChartCardProps {
title: string;
subtitle?: string;
icon?: Icon;
/** Tone for the icon and, by convention, the chart's primary series. */
color?: string;
badge?: ReactNode;
children: ReactNode;
empty?: boolean;
emptyText?: string;
/** Override for a chart that needs more room than the shared height. */
height?: number;
/**
* A caveat about the data itself, under the plot. For the things a reader
* would otherwise misread — a partial final bucket, an excluded series —
* which belong next to the marks rather than in a tooltip nobody opens.
*/
footnote?: ReactNode;
}
export function ChartCard({
title,
subtitle,
icon: ChartIcon,
color = 'blue',
badge,
children,
empty,
emptyText,
height = CHART_HEIGHT,
footnote,
}: ChartCardProps) {
return (
<Card withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{ChartIcon && (
<ThemeIcon size={32} radius="md" variant="light" color={color}>
<ChartIcon size={18} />
</ThemeIcon>
)}
<div>
<Text fw={600} size="sm" lh={1.3}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</div>
</Group>
{badge}
</Group>
{empty ? (
<Center h={height}>
<Text size="sm" c="dimmed" ta="center" maw={320}>
{emptyText ?? 'No data available for this chart yet.'}
</Text>
</Center>
) : (
<Box w="100%" h={height}>
<ResponsiveContainer width="100%" height="100%">
{children as never}
</ResponsiveContainer>
</Box>
)}
{!empty && footnote && (
<Text size="xs" c="dimmed" mt="xs">
{footnote}
</Text>
)}
</Card>
);
}

View File

@@ -1,4 +1,3 @@
import type { ReactNode } from 'react';
import {
Badge,
Box,
@@ -30,82 +29,17 @@ import {
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { AdminDashboardAnalytics } from '@ema-platform/api';
const CHART_HEIGHT = 280;
const AXIS_STYLE = { fontSize: 11, fill: 'var(--mantine-color-dimmed)' } as const;
const GRID_COLOR = 'var(--mantine-color-default-border)';
const TOOLTIP_BOX_STYLE = {
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
fontSize: 12,
} as const;
function ChartCard({
title,
subtitle,
icon: Icon,
badge,
children,
empty,
emptyText,
}: {
title: string;
subtitle?: string;
icon?: typeof IconTrendingUp;
badge?: ReactNode;
children: ReactNode;
empty?: boolean;
emptyText?: string;
}) {
return (
<Card withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{Icon && (
<ThemeIcon size={32} radius="md" variant="light" color="blue">
<Icon size={18} />
</ThemeIcon>
)}
<div>
<Text fw={600} size="sm" lh={1.3}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</div>
</Group>
{badge}
</Group>
{empty ? (
<Center h={CHART_HEIGHT}>
<Text size="sm" c="dimmed">
{emptyText ?? 'No data available for this chart yet.'}
</Text>
</Center>
) : (
<Box w="100%" h={CHART_HEIGHT}>
<ResponsiveContainer width="100%" height="100%">
{children as never}
</ResponsiveContainer>
</Box>
)}
</Card>
);
}
import {
AXIS_STYLE,
ChartCard,
GRID_COLOR,
TOOLTIP_BOX_STYLE,
} from '../../../../charts/ChartCard';
const CATEGORY_NAMES: Record<string, string> = {
CARGO_FREIGHT: 'Cargo & Freight',
@@ -213,7 +147,9 @@ export function DashboardCharts({
<YAxis tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number, name: string) => [
// Contextually typed by Recharts: annotating `val` as `number`
// fails, since a tooltip value may also be a string or absent.
formatter={(val, name) => [
val,
name === 'submitted'
? 'Submitted'
@@ -264,7 +200,7 @@ export function DashboardCharts({
<PieChart margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number, name: string) => [`${val} applications`, name]}
formatter={(val, name) => [`${val} applications`, name]}
/>
<Legend
layout="vertical"
@@ -314,7 +250,7 @@ export function DashboardCharts({
/>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number) => [`${val} applications`, 'Volume']}
formatter={(val) => [`${val} applications`, 'Volume']}
/>
<Bar dataKey="count" radius={[0, 6, 6, 0]} maxBarSize={22}>
{categoryData.map((entry, index) => (
@@ -453,7 +389,7 @@ export function DashboardCharts({
/>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
formatter={(val: number, name: string) => [
formatter={(val, name) => [
`${val} applications`,
name === 'onScheduleCount' ? 'On Schedule' : 'SLA Overdue',
]}

View File

@@ -7,8 +7,11 @@ import { computeSla } from './sla';
*
* Company names routinely contain commas, and remarks contain quotes and
* newlines — unescaped, either one shifts every later column on the row.
*
* Exported because the logistics head's departmental report writes CSV too,
* and a second escaper is a second chance to get quoting wrong.
*/
function csvCell(value: unknown): string {
export function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const text = String(value);
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;

View File

@@ -0,0 +1,613 @@
import { describe, expect, it } from 'vitest';
import type {
IssuedLicense,
LicenseApplication,
LicenseStatus,
LicenseType,
} from '@ema-platform/api';
import {
LOGISTICS_OPEN_STATUSES,
ageProfile,
bottleneckStage,
decorate,
intakeCohorts,
intakeWindowStart,
isLogisticsApplication,
isOpenStatus,
renewalRadar,
summariseOfficers,
summariseStages,
summariseTypes,
summarisePipeline,
recentFlow,
teamLoad,
withLicenseType,
worklist,
} from './logistics-metrics';
/** 2026-06-01T00:00:00Z, so every age in these tests is a fixed number. */
const NOW = Date.parse('2026-06-01T00:00:00.000Z');
const DAY = 86_400_000;
const daysAgo = (days: number) => new Date(NOW - days * DAY).toISOString();
function licenceType(overrides: Partial<LicenseType> = {}): LicenseType {
return {
id: 'type-ff',
key: 'FREIGHT_FORWARDER',
name: { en: 'Freight Forwarder' },
category: 'CARGO_FREIGHT',
familyKind: 'LOGISTICS_LICENSE',
certificatePrefix: 'FF',
feeNewApplication: 5000,
feeRenewal: 3000,
feeCurrency: 'ETB',
capitalThreshold: null,
validityMonths: 12,
// 10 days: warning from day 7, breached from day 10.
slaHours: 240,
inspectionRequired: true,
issuesCertificate: true,
renewalEnabled: true,
requiresIssuanceScheduling: false,
requiresOperatorMode: true,
formSchema: { sections: [] },
isActive: true,
sortOrder: 1,
...overrides,
};
}
let sequence = 0;
function application(
overrides: Partial<LicenseApplication> = {},
): LicenseApplication {
sequence += 1;
return {
id: `app-${sequence}`,
applicationNumber: `EMA-${1000 + sequence}`,
licenseTypeId: 'type-ff',
licenseType: licenceType(),
familyKind: 'LOGISTICS_LICENSE',
applicantUserId: 'user-1',
kind: 'NEW',
status: 'SUBMITTED',
assignedOfficerId: null,
claimedAt: null,
formData: {},
companyName: 'Blue Nile Freight PLC',
tradeName: null,
tinNumber: '0001234567',
businessAddress: null,
capitalAmountDeclared: null,
capitalAmountVerified: null,
adjustmentRound: 0,
submittedAt: daysAgo(1),
decidedAt: null,
rejectionReason: null,
feeAmount: null,
feeCurrency: 'ETB',
issuedLicenseId: null,
scheduledIssuanceDate: null,
scheduledIssuancePeriod: null,
scheduledBy: null,
createdAt: daysAgo(2),
...overrides,
};
}
const TYPES = new Map([['type-ff', licenceType()]]);
const run = (apps: LicenseApplication[]) => decorate(apps, TYPES, NOW);
describe('family scoping', () => {
it('counts only the logistics-licence family as the departments work', () => {
expect(isLogisticsApplication(application())).toBe(true);
expect(
isLogisticsApplication(application({ familyKind: 'CERTIFICATE' })),
).toBe(false);
});
});
describe('open statuses', () => {
it('treats the terminal outcomes as closed', () => {
expect(isOpenStatus('COMPLETED')).toBe(false);
expect(isOpenStatus('CERTIFICATE_ISSUED')).toBe(false);
expect(isOpenStatus('REJECTED')).toBe(false);
expect(isOpenStatus('DRAFT')).toBe(false);
});
it('covers every stage a logistics licence passes through', () => {
const expected: LicenseStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'RESUBMIT_REQUIRED',
'APPROVED',
'PAYMENT_PENDING',
'PAYMENT_CONFIRMED',
'ON_HOLD',
];
for (const status of expected) {
expect(LOGISTICS_OPEN_STATUSES).toContain(status);
}
});
});
describe('withLicenseType', () => {
it('fills the relation from the catalogue so the SLA is not silently untracked', () => {
const bare = application({ licenseType: undefined, submittedAt: daysAgo(20) });
expect(decorate([bare], new Map(), NOW)[0].sla.state).toBe('untracked');
expect(decorate([bare], TYPES, NOW)[0].sla.state).toBe('breached');
});
it('leaves an application that already carries its type alone', () => {
const app = application();
expect(withLicenseType(app, new Map())).toBe(app);
});
});
describe('summarisePipeline', () => {
const rows = run([
// Waiting to be handed out, and late.
application({ submittedAt: daysAgo(12) }),
// Waiting to be handed out, fresh.
application({ submittedAt: daysAgo(1) }),
// With an officer, inside the amber band (day 8 of a 10-day target).
application({
status: 'UNDER_REVIEW',
assignedOfficerId: 'officer-a',
submittedAt: daysAgo(8),
}),
application({
status: 'RESUBMIT_REQUIRED',
assignedOfficerId: 'officer-a',
submittedAt: daysAgo(3),
}),
application({
status: 'INSPECTION_PENDING',
assignedOfficerId: 'officer-b',
submittedAt: daysAgo(5),
}),
application({
status: 'PAYMENT_CONFIRMED',
assignedOfficerId: 'officer-b',
submittedAt: daysAgo(4),
}),
application({
status: 'PAYMENT_PENDING',
assignedOfficerId: 'officer-b',
submittedAt: daysAgo(2),
}),
application({
status: 'ON_HOLD',
assignedOfficerId: 'officer-a',
submittedAt: daysAgo(30),
}),
]);
const totals = summarisePipeline(rows);
it('separates what the head must dispatch from what officers already hold', () => {
expect(totals.open).toBe(8);
expect(totals.awaitingDispatch).toBe(2);
expect(totals.withOfficers).toBe(6);
});
it('reads the SLA state rather than the age', () => {
// Day 30 (on hold) and day 12 are past a 10-day target; day 8 is amber.
expect(totals.overdue).toBe(2);
expect(totals.atRisk).toBe(1);
expect(totals.slaTracked).toBe(8);
expect(totals.slaCompliance).toBe(75);
});
it('counts the stages a head acts on', () => {
expect(totals.waitingOnApplicant).toBe(1);
expect(totals.inInspection).toBe(1);
expect(totals.awaitingPayment).toBe(1);
expect(totals.readyToIssue).toBe(1);
expect(totals.onHold).toBe(1);
});
it('reports the age of the pipeline, not just its size', () => {
expect(totals.oldestDays).toBe(30);
// Ages 1,2,3,4,5,8,12,30 → mean of the middle pair.
expect(totals.medianDays).toBe(5);
});
it('reads full compliance when no licence type has a target set', () => {
const untracked = decorate(
[application({ licenseType: licenceType({ slaHours: null }) })],
new Map(),
NOW,
);
const summary = summarisePipeline(untracked);
expect(summary.slaTracked).toBe(0);
expect(summary.slaCompliance).toBe(100);
});
it('reads zeroes on an empty department rather than NaN', () => {
const summary = summarisePipeline([]);
expect(summary.open).toBe(0);
expect(summary.medianDays).toBe(0);
expect(summary.slaCompliance).toBe(100);
});
});
describe('recentFlow', () => {
it('counts what arrived inside the window, not the whole backlog', () => {
const rows = run([
application({ submittedAt: daysAgo(1) }),
application({ submittedAt: daysAgo(6) }),
application({ submittedAt: daysAgo(8) }),
application({ submittedAt: daysAgo(40) }),
]);
expect(recentFlow(rows, 7, NOW).arrived).toBe(2);
expect(recentFlow(rows, 7, NOW).days).toBe(7);
});
it('dates a breach from submission plus the types own window', () => {
const rows = run([
// 10-day target. Submitted 12 days ago → went late 2 days ago: inside.
application({ submittedAt: daysAgo(12) }),
// Submitted 30 days ago → went late 20 days ago: outside a 7-day window.
application({ submittedAt: daysAgo(30) }),
// Amber, not breached, so it counts in neither.
application({ submittedAt: daysAgo(8) }),
]);
expect(recentFlow(rows, 7, NOW).breached).toBe(1);
expect(recentFlow(rows, 30, NOW).breached).toBe(2);
});
it('never counts a licence type with no target as newly late', () => {
const untracked = decorate(
[
application({
licenseType: licenceType({ slaHours: null }),
submittedAt: daysAgo(400),
}),
],
new Map(),
NOW,
);
expect(recentFlow(untracked, 7, NOW).breached).toBe(0);
});
});
describe('teamLoad', () => {
it('measures the median across officers, ignoring the unassigned pool', () => {
const rows = run([
application({ assignedOfficerId: 'a' }),
application({ assignedOfficerId: 'a' }),
application({ assignedOfficerId: 'a' }),
application({ assignedOfficerId: 'b' }),
// A large unassigned pool must not drag the team's median up.
application(),
application(),
application(),
application(),
application(),
]);
const loads = summariseOfficers(rows, [], 'Unassigned');
expect(teamLoad(loads)).toEqual({ median: 2, max: 3 });
});
it('reads a max of at least 1 on an empty team, so bars never divide by zero', () => {
expect(teamLoad([])).toEqual({ median: 0, max: 1 });
});
});
describe('summariseStages', () => {
it('keeps empty stages so a clear stage is distinguishable from an absent one', () => {
const stages = summariseStages(run([application()]));
expect(stages).toHaveLength(8);
expect(stages.map((s) => s.stage)).toEqual([
'intake',
'review',
'evaluation',
'inspection',
'applicant',
'payment',
'issuance',
'hold',
]);
expect(stages.find((s) => s.stage === 'inspection')?.count).toBe(0);
});
it('folds the four inspection statuses into one stage', () => {
const stages = summariseStages(
run([
application({ status: 'INSPECTION_PENDING' }),
application({ status: 'INSPECTION_COMPLETED' }),
application({ status: 'INSPECTION_REPORTED' }),
application({ status: 'INSPECTION_FAILED' }),
]),
);
expect(stages.find((s) => s.stage === 'inspection')?.count).toBe(4);
});
});
describe('bottleneckStage', () => {
it('picks the slowest stage, not the biggest one', () => {
const stages = summariseStages(
run([
// A busy but fast intake.
application({ submittedAt: daysAgo(1) }),
application({ submittedAt: daysAgo(1) }),
application({ submittedAt: daysAgo(1) }),
application({ submittedAt: daysAgo(1) }),
// A small but stalled inspection stage.
application({ status: 'INSPECTION_PENDING', submittedAt: daysAgo(25) }),
application({ status: 'INSPECTION_PENDING', submittedAt: daysAgo(21) }),
]),
);
expect(bottleneckStage(stages)?.stage).toBe('inspection');
});
it('ignores a single forgotten file', () => {
const stages = summariseStages(
run([application({ status: 'ON_HOLD', submittedAt: daysAgo(90) })]),
);
expect(bottleneckStage(stages)).toBeUndefined();
});
it('reports nothing when the whole department is same-day', () => {
const stages = summariseStages(
run([
application({ submittedAt: daysAgo(0) }),
application({ submittedAt: daysAgo(0) }),
]),
);
expect(bottleneckStage(stages)).toBeUndefined();
});
});
describe('ageProfile', () => {
it('bands the pipeline without dropping or double-counting a row', () => {
const rows = run([
application({ submittedAt: daysAgo(0) }),
application({ submittedAt: daysAgo(3) }),
application({ submittedAt: daysAgo(4) }),
application({ submittedAt: daysAgo(14) }),
application({ submittedAt: daysAgo(15) }),
application({ submittedAt: daysAgo(120) }),
]);
const buckets = ageProfile(rows);
expect(buckets.map((b) => b.count)).toEqual([2, 1, 1, 1, 1]);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(rows.length);
});
});
/** A real officer id is a uuid; the fallback label shows its first 8 characters. */
const LAPSED_OFFICER_ID = '3f2b1a90-77c4-4d0e-9f1a-6b25c8e4d012';
describe('summariseOfficers', () => {
const rows = run([
application({ assignedOfficerId: 'officer-a', submittedAt: daysAgo(12) }),
application({
status: 'UNDER_REVIEW',
assignedOfficerId: 'officer-a',
submittedAt: daysAgo(2),
}),
application({
status: 'UNDER_REVIEW',
assignedOfficerId: LAPSED_OFFICER_ID,
submittedAt: daysAgo(1),
}),
application({ submittedAt: daysAgo(4) }),
]);
const loads = summariseOfficers(
rows,
[{ id: 'officer-a', name: 'Hanna T.' }],
'Unassigned',
);
it('carries the unassigned pool as a row of its own', () => {
expect(loads.find((load) => load.officerId === null)?.name).toBe('Unassigned');
expect(loads.find((load) => load.officerId === null)?.active).toBe(1);
});
it('sorts the officer in trouble to the top', () => {
expect(loads[0].officerId).toBe('officer-a');
expect(loads[0].overdue).toBe(1);
expect(loads[0].onTrack).toBe(1);
expect(loads[0].oldestDays).toBe(12);
});
it('still shows an officer who has dropped off the assignable list', () => {
expect(loads.find((load) => load.officerId === LAPSED_OFFICER_ID)?.name).toBe(
'#3f2b1a90',
);
});
});
describe('summariseTypes', () => {
it('drops catalogue types with nothing in flight', () => {
const other = licenceType({ id: 'type-sa', key: 'SHIPPING_AGENT' });
const loads = summariseTypes(
run([application({ submittedAt: daysAgo(12) }), application()]),
[licenceType(), other],
);
expect(loads).toHaveLength(1);
expect(loads[0].type.key).toBe('FREIGHT_FORWARDER');
expect(loads[0].open).toBe(2);
expect(loads[0].unassigned).toBe(2);
expect(loads[0].overdue).toBe(1);
});
});
describe('intakeWindowStart', () => {
it('lands on the first of the month, `months` back inclusive of this one', () => {
// NOW is 1 June 2026: a six-month window opens on 1 January.
expect(intakeWindowStart(6, NOW)).toBe('2026-01-01');
expect(intakeWindowStart(3, NOW)).toBe('2026-04-01');
expect(intakeWindowStart(12, NOW)).toBe('2025-07-01');
});
it('does not overflow a short month when today is the 31st', () => {
// The regression this guards: `date.setMonth(m - 5)` from 31 August keeps
// the day, and 31 February rolls forward into March — losing February from
// the window entirely.
const aug31 = Date.parse('2026-08-31T12:00:00.000Z');
expect(intakeWindowStart(7, aug31)).toBe('2026-02-01');
expect(intakeWindowStart(6, aug31)).toBe('2026-03-01');
});
it('crosses the year boundary', () => {
const jan31 = Date.parse('2026-01-31T12:00:00.000Z');
expect(intakeWindowStart(12, jan31)).toBe('2025-02-01');
expect(intakeWindowStart(3, jan31)).toBe('2025-11-01');
});
it('agrees with the first bucket intakeCohorts draws', () => {
for (const [months, from] of [[3, NOW], [6, NOW], [12, NOW]] as const) {
const first = intakeCohorts([], months, from)[0];
expect(intakeWindowStart(months, from)).toBe(`${first.key}-01`);
}
});
});
describe('intakeCohorts', () => {
it('emits an unbroken run of months, including quiet ones', () => {
const cohorts = intakeCohorts([], 6, NOW);
expect(cohorts.map((c) => c.key)).toEqual([
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
]);
expect(cohorts.every((c) => c.submitted === 0)).toBe(true);
});
it('flags only the month in progress', () => {
const cohorts = intakeCohorts([], 6, NOW);
expect(cohorts.filter((c) => c.isCurrent).map((c) => c.key)).toEqual([
'2026-06',
]);
});
it('splits each months intake by how it ended up', () => {
const cohorts = intakeCohorts(
[
application({ submittedAt: '2026-04-04T00:00:00.000Z', status: 'COMPLETED' }),
application({
submittedAt: '2026-04-09T00:00:00.000Z',
status: 'CERTIFICATE_ISSUED',
}),
application({ submittedAt: '2026-04-20T00:00:00.000Z', status: 'REJECTED' }),
application({ submittedAt: '2026-04-28T00:00:00.000Z', status: 'UNDER_REVIEW' }),
],
6,
NOW,
);
const april = cohorts.find((c) => c.key === '2026-04');
expect(april).toMatchObject({ submitted: 4, issued: 2, rejected: 1, open: 1 });
expect(april?.isCurrent).toBe(false);
});
it('ignores anything submitted outside the window', () => {
const cohorts = intakeCohorts(
[application({ submittedAt: '2024-01-01T00:00:00.000Z' })],
6,
NOW,
);
expect(cohorts.reduce((sum, c) => sum + c.submitted, 0)).toBe(0);
});
});
describe('renewalRadar', () => {
const licence = (
overrides: Partial<IssuedLicense> & Pick<IssuedLicense, 'id'>,
): IssuedLicense => ({
certificateNumber: 'FF-0001',
licenseTypeId: 'type-ff',
familyKind: 'LOGISTICS_LICENSE',
applicationId: 'app-1',
companyName: 'Blue Nile Freight PLC',
tinNumber: '0001234567',
issueDate: daysAgo(300),
expiryDate: daysAgo(-20),
status: 'ACTIVE',
verificationCode: 'ABC123',
certificateFileKey: null,
...overrides,
});
it('bands live licences into disjoint renewal windows', () => {
const radar = renewalRadar(
[
licence({ id: 'l1', daysUntilExpiry: 10 }),
licence({ id: 'l2', daysUntilExpiry: 30 }),
licence({ id: 'l3', daysUntilExpiry: 31 }),
licence({ id: 'l4', daysUntilExpiry: 90 }),
licence({ id: 'l5', daysUntilExpiry: 200 }),
],
NOW,
);
expect(radar.active).toBe(5);
expect(radar.within30).toBe(2);
expect(radar.within60).toBe(1);
expect(radar.within90).toBe(1);
expect(radar.upcoming.map((l) => l.id)).toEqual(['l1', 'l2', 'l3', 'l4']);
});
it('excludes certificates belonging to another department', () => {
const radar = renewalRadar(
[licence({ id: 'l1', familyKind: 'CERTIFICATE', daysUntilExpiry: 5 })],
NOW,
);
expect(radar.active).toBe(0);
expect(radar.within30).toBe(0);
});
it('falls back to the expiry date when the API omits the day count', () => {
const radar = renewalRadar(
[licence({ id: 'l1', expiryDate: new Date(NOW + 5 * DAY).toISOString() })],
NOW,
);
expect(radar.within30).toBe(1);
});
it('counts a lapsed licence as expired, never as due for renewal', () => {
const radar = renewalRadar(
[licence({ id: 'l1', status: 'EXPIRED', daysUntilExpiry: -4 })],
NOW,
);
expect(radar.expired).toBe(1);
expect(radar.within30).toBe(0);
expect(radar.upcoming).toHaveLength(0);
});
});
describe('worklist', () => {
const rows = run([
application({ submittedAt: daysAgo(2) }),
application({ submittedAt: daysAgo(9) }),
application({
status: 'UNDER_REVIEW',
assignedOfficerId: 'officer-a',
submittedAt: daysAgo(12) ,
}),
application({ status: 'RESUBMIT_REQUIRED', submittedAt: daysAgo(6) }),
application({ status: 'PAYMENT_CONFIRMED', submittedAt: daysAgo(1) }),
]);
it('offers dispatch oldest-first, and only what is genuinely unassigned', () => {
const list = worklist('dispatch', rows);
expect(list).toHaveLength(2);
expect(list[0].ageDays).toBe(9);
});
it('puts the most breached SLA at the top of the priority list', () => {
const list = worklist('sla', rows);
expect(list[0].sla.state).toBe('breached');
expect(list.map((row) => row.sla.state)).not.toContain('ok');
});
it('separates what the applicant owes from what the department owes', () => {
expect(worklist('applicant', rows)).toHaveLength(1);
expect(worklist('issue', rows)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,712 @@
import type {
AssignableOfficer,
IssuedLicense,
LicenseApplication,
LicenseStatus,
LicenseType,
} from '@ema-platform/api';
import { computeSla, type SlaState } from '../license-review/sla';
/**
* Everything the logistics head's dashboard counts, as pure functions.
*
* Kept out of the page for two reasons. The obvious one is that this is the
* only part of the screen that can be unit-tested — the vite config runs
* `*.test.ts` in a node environment with no jsdom, so a component test is not
* on offer and an untested aggregation would be a dashboard nobody can prove
* is right. The second is that the previous version of this page summed six
* hardcoded arrays, which is exactly the class of mistake that survives review
* when the arithmetic is buried inside JSX.
*
* Every figure here is derived from the licence applications the API actually
* returns, scoped to the logistics-licence family, and every function takes
* `now` so the tests are not clock-dependent.
*/
const DAY_MS = 86_400_000;
// --------------------------------------------------------------- the family
/**
* The department's own work.
*
* Branches on `familyKind`, the real data-model column, rather than a
* hand-kept key list — a new operator licence type configured in the
* backoffice lands on this dashboard with no code change (BR-MTO-020).
*/
export function isLogisticsApplication(app: LicenseApplication): boolean {
return app.familyKind === 'LOGISTICS_LICENSE';
}
export function isLogisticsLicence(licence: IssuedLicense): boolean {
return licence.familyKind === 'LOGISTICS_LICENSE';
}
// --------------------------------------------------------------- the stages
/**
* Where an open application is sitting, in the words a department head uses.
*
* Coarser than `LicenseStatus` on purpose: the head is asking "where is work
* piling up", and the answer "eleven files are in inspection" is more useful
* than four separate inspection statuses adding up to the same eleven.
*/
export type LogisticsStage =
| 'intake'
| 'review'
| 'evaluation'
| 'inspection'
| 'applicant'
| 'payment'
| 'issuance'
| 'hold';
/**
* Status → stage. Doubles as the definition of "open": a status absent from
* this map is terminal (issued, completed, rejected) or a draft that was never
* filed, and neither is the department's outstanding work.
*/
const STAGE_BY_STATUS: Partial<Record<LicenseStatus, LogisticsStage>> = {
SUBMITTED: 'intake',
UNDER_REVIEW: 'review',
REVIEW_REPORTED: 'review',
UNDER_EVALUATION: 'evaluation',
INSPECTION_PENDING: 'inspection',
INSPECTION_COMPLETED: 'inspection',
INSPECTION_REPORTED: 'inspection',
INSPECTION_FAILED: 'inspection',
RESUBMIT_REQUIRED: 'applicant',
APPROVED: 'payment',
PAYMENT_PENDING: 'payment',
PAID: 'payment',
PAYMENT_CONFIRMED: 'issuance',
SCHEDULED: 'issuance',
ON_HOLD: 'hold',
};
/** Display order of the stages, left to right along the pipeline. */
export const STAGE_ORDER: LogisticsStage[] = [
'intake',
'review',
'evaluation',
'inspection',
'applicant',
'payment',
'issuance',
'hold',
];
/** Statuses to ask the server for when loading the open pipeline. */
export const LOGISTICS_OPEN_STATUSES = Object.keys(
STAGE_BY_STATUS,
) as LicenseStatus[];
/** Statuses that have left the department for good, either way. */
export const LOGISTICS_CLOSED_STATUSES: LicenseStatus[] = [
'CERTIFICATE_ISSUED',
'COMPLETED',
'REJECTED',
];
export function stageOf(status: LicenseStatus): LogisticsStage | undefined {
return STAGE_BY_STATUS[status];
}
/**
* The statuses a stage folds together, for deep-linking the queue's status
* facet. Derived from the same map the board counts with, so a link can never
* open a filter that disagrees with the number that was clicked.
*/
export function statusesInStage(stage: LogisticsStage): LicenseStatus[] {
return (Object.entries(STAGE_BY_STATUS) as Array<[LicenseStatus, LogisticsStage]>)
.filter(([, value]) => value === stage)
.map(([status]) => status);
}
export function isOpenStatus(status: LicenseStatus): boolean {
return STAGE_BY_STATUS[status] !== undefined;
}
// ----------------------------------------------------------- one application
export interface DecoratedApplication {
app: LicenseApplication;
/** Undefined only for a terminal row that slipped into an "open" set. */
stage: LogisticsStage | undefined;
sla: SlaState;
/** Whole days since submission (or creation, for anything never submitted). */
ageDays: number;
/** Null for the unclaimed pool — the head's own to-do list. */
officerId: string | null;
}
/**
* The queue endpoints normally embed `licenseType`, but not every payload does
* (a cached response from before the join, a lean list projection). Without it
* `computeSla` reads no `slaHours` and reports every row as untracked, which
* would silently zero the SLA figures rather than fail loudly. Filling the
* relation from the catalogue we already loaded keeps the dashboard and the
* queue agreeing on what is late.
*/
export function withLicenseType(
app: LicenseApplication,
typesById: Map<string, LicenseType>,
): LicenseApplication {
if (app.licenseType) return app;
const type = typesById.get(app.licenseTypeId);
return type ? { ...app, licenseType: type } : app;
}
export function ageInDays(app: LicenseApplication, now: number): number {
const since = app.submittedAt ?? app.createdAt;
if (!since) return 0;
const started = new Date(since).getTime();
if (Number.isNaN(started)) return 0;
// Clock skew between the server and the browser must not read as a negative
// age, which would sort a fresh application to the top of an "oldest" list.
return Math.max(0, Math.floor((now - started) / DAY_MS));
}
export function decorate(
apps: LicenseApplication[],
typesById: Map<string, LicenseType>,
now: number,
): DecoratedApplication[] {
return apps.map((raw) => {
const app = withLicenseType(raw, typesById);
return {
app,
stage: stageOf(app.status),
sla: computeSla(app, now),
ageDays: ageInDays(app, now),
officerId: app.assignedOfficerId,
};
});
}
// -------------------------------------------------------------- the headline
export interface PipelineTotals {
/** Everything still in the department. */
open: number;
/** Filed, nobody assigned — the head's own queue. */
awaitingDispatch: number;
/** Open and in an officer's hands. */
withOfficers: number;
overdue: number;
atRisk: number;
/** Nothing moves until the applicant acts. */
waitingOnApplicant: number;
inInspection: number;
awaitingPayment: number;
/** Paid and confirmed — the certificate is the only thing left to do. */
readyToIssue: number;
onHold: number;
/** Open rows whose licence type actually has a turnaround target set. */
slaTracked: number;
/** Percentage of tracked open rows not past their target. 100 when none. */
slaCompliance: number;
oldestDays: number;
medianDays: number;
}
const PAYMENT_STATUSES: LicenseStatus[] = ['APPROVED', 'PAYMENT_PENDING', 'PAID'];
export function summarisePipeline(rows: DecoratedApplication[]): PipelineTotals {
const ages = rows.map((row) => row.ageDays).sort((a, b) => a - b);
const tracked = rows.filter((row) => row.sla.state !== 'untracked');
const overdue = rows.filter((row) => row.sla.state === 'breached').length;
return {
open: rows.length,
awaitingDispatch: rows.filter(
(row) => row.officerId === null && row.stage === 'intake',
).length,
withOfficers: rows.filter((row) => row.officerId !== null).length,
overdue,
atRisk: rows.filter((row) => row.sla.state === 'warning').length,
waitingOnApplicant: rows.filter((row) => row.stage === 'applicant').length,
inInspection: rows.filter((row) => row.stage === 'inspection').length,
awaitingPayment: rows.filter((row) =>
PAYMENT_STATUSES.includes(row.app.status),
).length,
readyToIssue: rows.filter((row) => row.stage === 'issuance').length,
onHold: rows.filter((row) => row.stage === 'hold').length,
slaTracked: tracked.length,
// No target configured anywhere means nothing can be late, which reads as
// full compliance rather than as a division by zero.
slaCompliance: tracked.length
? Math.round(((tracked.length - overdue) / tracked.length) * 100)
: 100,
oldestDays: ages.length ? ages[ages.length - 1] : 0,
medianDays: median(ages),
};
}
function median(sortedAges: number[]): number {
if (sortedAges.length === 0) return 0;
const mid = Math.floor(sortedAges.length / 2);
return sortedAges.length % 2
? sortedAges[mid]
: Math.round((sortedAges[mid - 1] + sortedAges[mid]) / 2);
}
// ---------------------------------------------------------------- the stages
export interface StageLoad {
stage: LogisticsStage;
count: number;
overdue: number;
oldestDays: number;
medianDays: number;
}
/**
* One row per stage, in pipeline order, including the empty ones.
*
* Empty stages are kept deliberately: a board that hides "Inspection" when it
* is clear looks identical to a board where inspections were never configured,
* and the two mean opposite things.
*/
export function summariseStages(rows: DecoratedApplication[]): StageLoad[] {
return STAGE_ORDER.map((stage) => {
const inStage = rows.filter((row) => row.stage === stage);
const ages = inStage.map((row) => row.ageDays).sort((a, b) => a - b);
return {
stage,
count: inStage.length,
overdue: inStage.filter((row) => row.sla.state === 'breached').length,
oldestDays: ages.length ? ages[ages.length - 1] : 0,
medianDays: median(ages),
};
});
}
/**
* The stage holding work up.
*
* Median age, not count: twenty files that each arrived this morning are a
* busy stage, not a blocked one, and a head sending help to the biggest column
* would be sending it to the wrong place. Stages below `minCount` are ignored
* so a single forgotten file cannot present itself as a department-wide
* bottleneck.
*/
export function bottleneckStage(
stages: StageLoad[],
minCount = 2,
): StageLoad | undefined {
const candidates = stages.filter(
(stage) => stage.count >= minCount && stage.medianDays > 0,
);
if (candidates.length === 0) return undefined;
return candidates.reduce((worst, stage) =>
stage.medianDays > worst.medianDays ? stage : worst,
);
}
// ----------------------------------------------------------------- the ageing
export interface AgeBucket {
/** i18n key suffix and chart label, e.g. "0-3". */
id: string;
/** Exclusive upper bound in days; `Infinity` for the last bucket. */
maxDays: number;
count: number;
tone: 'neutral' | 'info' | 'warning' | 'danger';
}
const AGE_BUCKET_SPEC: Array<Pick<AgeBucket, 'id' | 'maxDays' | 'tone'>> = [
{ id: '0-3', maxDays: 4, tone: 'neutral' },
{ id: '4-7', maxDays: 8, tone: 'info' },
{ id: '8-14', maxDays: 15, tone: 'info' },
{ id: '15-30', maxDays: 31, tone: 'warning' },
{ id: '30+', maxDays: Infinity, tone: 'danger' },
];
/** How long the open pipeline has been open, in bands. */
export function ageProfile(rows: DecoratedApplication[]): AgeBucket[] {
return AGE_BUCKET_SPEC.map((spec, index) => {
const floor = index === 0 ? 0 : AGE_BUCKET_SPEC[index - 1].maxDays;
return {
...spec,
count: rows.filter(
(row) => row.ageDays >= floor && row.ageDays < spec.maxDays,
).length,
};
});
}
// ------------------------------------------------------------------ the flow
export interface FlowTrend {
/** Still-open applications that arrived inside the window. */
arrived: number;
/** Open applications whose target passed inside the window. */
breached: number;
/** The window itself, so the label can name it. */
days: number;
}
/**
* What changed recently, as far as today's data can honestly say.
*
* A tile showing "52 open" begs the question "up or down?", and the obvious
* answer — a period-over-period delta — is not available: the API stores no
* snapshot of what the queue looked like last week, so "52, up 6" would be
* invented. What *is* derivable from the rows in hand is flow: every open
* application carries the moment it arrived, and its licence type carries the
* window after which it turns late, so the moment it breached is arithmetic
* rather than history. Both are real numbers about a real period, and neither
* pretends to be a stock comparison.
*/
export function recentFlow(
rows: DecoratedApplication[],
days: number,
now: number,
): FlowTrend {
const since = now - days * DAY_MS;
const arrived = rows.filter((row) => {
const at = row.app.submittedAt ?? row.app.createdAt;
if (!at) return false;
const ms = new Date(at).getTime();
return !Number.isNaN(ms) && ms >= since;
}).length;
const breached = rows.filter((row) => {
if (row.sla.state !== 'breached') return false;
const slaHours = row.app.licenseType?.slaHours;
if (!slaHours || !row.app.submittedAt) return false;
const ms = new Date(row.app.submittedAt).getTime();
if (Number.isNaN(ms)) return false;
// The instant it went late: submission plus the type's own window.
return ms + slaHours * 60 * 60 * 1000 >= since;
}).length;
return { arrived, breached, days };
}
// ---------------------------------------------------------------- the people
export interface OfficerLoad {
/** Null is the unassigned pool, carried as a row so it is never invisible. */
officerId: string | null;
name: string;
active: number;
overdue: number;
atRisk: number;
onTrack: number;
oldestDays: number;
}
/**
* Who is holding what.
*
* The unassigned pool is one of the rows rather than a separate figure: to a
* team leader deciding where the next file goes, "nobody" is a workload like
* any other, and it is the only one they can act on directly.
*/
export function summariseOfficers(
rows: DecoratedApplication[],
officers: AssignableOfficer[],
unassignedLabel: string,
): OfficerLoad[] {
const names = new Map(officers.map((officer) => [officer.id, officer.name]));
const groups = new Map<string | null, DecoratedApplication[]>();
for (const row of rows) {
const key = row.officerId;
const bucket = groups.get(key);
if (bucket) bucket.push(row);
else groups.set(key, [row]);
}
const loads: OfficerLoad[] = [];
for (const [officerId, group] of groups) {
const overdue = group.filter((row) => row.sla.state === 'breached').length;
const atRisk = group.filter((row) => row.sla.state === 'warning').length;
loads.push({
officerId,
name:
officerId === null
? unassignedLabel
: // An officer who has left the assignable list still holds files;
// showing a truncated id beats dropping their column entirely.
names.get(officerId) ?? `#${officerId.slice(0, 8)}`,
active: group.length,
overdue,
atRisk,
onTrack: group.length - overdue - atRisk,
oldestDays: group.reduce((max, row) => Math.max(max, row.ageDays), 0),
});
}
// Most at-risk first — the point of the table is who needs help, not who is
// alphabetically first. The unassigned pool sorts on the same rule as
// everyone else rather than being pinned, so a healthy pool drops out of the
// way instead of occupying the most valuable row on the screen.
return loads.sort(
(a, b) =>
b.overdue - a.overdue || b.atRisk - a.atRisk || b.active - a.active,
);
}
export interface TeamLoad {
/** Median open files across officers. The unassigned pool is not a person. */
median: number;
/** Heaviest single officer, for scaling the bars against each other. */
max: number;
}
/**
* The team's own shape, used as the capacity reference.
*
* There is no configured work-in-progress norm anywhere in the data model, and
* inventing one would be worse than having none. The team's median load is the
* next best thing and is entirely real: it answers "is this officer carrying
* more than their colleagues", which is the question behind reassignment.
*/
export function teamLoad(loads: OfficerLoad[]): TeamLoad {
const active = loads
.filter((load) => load.officerId !== null)
.map((load) => load.active)
.sort((a, b) => a - b);
return { median: median(active), max: Math.max(1, ...active) };
}
// ----------------------------------------------------------------- the types
export interface TypeLoad {
type: LicenseType;
open: number;
unassigned: number;
overdue: number;
atRisk: number;
medianDays: number;
oldestDays: number;
}
/** Per licence type, for the catalogue rows that actually have work in them. */
export function summariseTypes(
rows: DecoratedApplication[],
types: LicenseType[],
): TypeLoad[] {
return types
.map((type) => {
const inType = rows.filter((row) => row.app.licenseTypeId === type.id);
const ages = inType.map((row) => row.ageDays).sort((a, b) => a - b);
return {
type,
open: inType.length,
unassigned: inType.filter((row) => row.officerId === null).length,
overdue: inType.filter((row) => row.sla.state === 'breached').length,
atRisk: inType.filter((row) => row.sla.state === 'warning').length,
medianDays: median(ages),
oldestDays: ages.length ? ages[ages.length - 1] : 0,
};
})
.filter((load) => load.open > 0)
.sort((a, b) => b.overdue - a.overdue || b.open - a.open);
}
// ------------------------------------------------------------- the throughput
export interface IntakeCohort {
/** Sortable month key, `YYYY-MM`. */
key: string;
/** Short month label for the axis. */
label: string;
submitted: number;
issued: number;
rejected: number;
/** Submitted in this month and still in the department. */
open: number;
/**
* The month in progress. Its bar is always short — the month is not over —
* so a chart that draws it like the others reads as a collapse in intake
* every time anyone looks at it before the 28th.
*/
isCurrent: boolean;
}
/**
* Intake by month, split by how each month's cohort ended up.
*
* A cohort rather than a decision-date series, because the history query is
* filtered on `submittedAt` — charting decisions by decision date over a set
* selected by submission date would draw a curve that tails off for reasons
* that have nothing to do with the department's throughput. Grouping by the
* field the data was actually selected on keeps the chart honest, and "of the
* 40 we took in March, 31 are issued and 4 are still open" is the question a
* head is asking anyway.
*
* Months with no intake are emitted as zeroes so the axis stays evenly spaced.
*/
export function intakeCohorts(
apps: LicenseApplication[],
months: number,
now: number,
locale = 'en',
): IntakeCohort[] {
const end = new Date(now);
const cohorts = new Map<string, IntakeCohort>();
for (let back = months - 1; back >= 0; back--) {
const month = new Date(
Date.UTC(end.getUTCFullYear(), end.getUTCMonth() - back, 1),
);
const key = monthKey(month);
cohorts.set(key, {
key,
// Gregorian month names even in Amharic. The buckets are Gregorian
// months — that is how `submittedAt` is stored — and an Ethiopian month
// name does not line up with one, so labelling them with `ethiopic`
// would put a name on a bar that spans two of those months.
label: month.toLocaleDateString(locale.startsWith('am') ? 'en' : locale, {
month: 'short',
}),
submitted: 0,
issued: 0,
rejected: 0,
open: 0,
isCurrent: back === 0,
});
}
for (const app of apps) {
const since = app.submittedAt ?? app.createdAt;
if (!since) continue;
const date = new Date(since);
if (Number.isNaN(date.getTime())) continue;
const cohort = cohorts.get(monthKey(date));
if (!cohort) continue;
cohort.submitted += 1;
if (app.status === 'REJECTED') cohort.rejected += 1;
else if (isOpenStatus(app.status)) cohort.open += 1;
else cohort.issued += 1;
}
return [...cohorts.values()];
}
/**
* First day of the earliest month an intake window covers, as `YYYY-MM-DD`.
*
* Built from UTC year/month parts rather than by mutating a `Date`.
* `setMonth()` keeps the current day-of-month, so on the 31st it overflows a
* shorter target month into the next one — 31 August minus six months lands on
* 3 March, not 1 February — and the window silently loses its oldest month for
* the ~19 days of each year where that applies. Shares its arithmetic with
* `intakeCohorts` below, so the query window and the chart's axis are
* guaranteed to begin on the same month.
*/
export function intakeWindowStart(months: number, now: number): string {
const today = new Date(now);
return new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - (months - 1), 1),
)
.toISOString()
.slice(0, 10);
}
function monthKey(date: Date): string {
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
}
// ---------------------------------------------------------------- the renewals
export interface RenewalRadar {
active: number;
/** Live licences expiring within 30 / 60 / 90 days, as disjoint bands. */
within30: number;
within60: number;
within90: number;
expired: number;
suspended: number;
/** The soonest to expire, for the panel's list. Never includes expired ones. */
upcoming: IssuedLicense[];
}
/**
* What the department has to renew.
*
* `daysUntilExpiry` comes from the API, computed in the authority's timezone —
* a browser in another zone re-deriving it from `expiryDate` would land on a
* different day, so it is only recomputed when the server omitted it.
*/
export function renewalRadar(
licences: IssuedLicense[],
now: number,
upcomingLimit = 6,
): RenewalRadar {
const logistics = licences.filter(isLogisticsLicence);
const daysLeft = (licence: IssuedLicense): number =>
licence.daysUntilExpiry ??
Math.ceil((new Date(licence.expiryDate).getTime() - now) / DAY_MS);
const live = logistics.filter((licence) => licence.status === 'ACTIVE');
const band = (from: number, to: number) =>
live.filter((licence) => {
const days = daysLeft(licence);
return days >= from && days <= to;
}).length;
return {
active: live.length,
within30: band(0, 30),
within60: band(31, 60),
within90: band(61, 90),
expired: logistics.filter(
(licence) => licence.status === 'EXPIRED' || daysLeft(licence) < 0,
).length,
suspended: logistics.filter((licence) => licence.status === 'SUSPENDED').length,
upcoming: live
.filter((licence) => daysLeft(licence) >= 0 && daysLeft(licence) <= 90)
.sort((a, b) => daysLeft(a) - daysLeft(b))
.slice(0, upcomingLimit),
};
}
/** Days to expiry as the radar reads it — exported so the panel agrees with it. */
export function daysUntilExpiry(licence: IssuedLicense, now: number): number {
return (
licence.daysUntilExpiry ??
Math.ceil((new Date(licence.expiryDate).getTime() - now) / DAY_MS)
);
}
// ------------------------------------------------------------- the worklists
export type WorklistId = 'dispatch' | 'sla' | 'applicant' | 'issue';
/**
* The four lists a head works from, as filters over the already-decorated
* pipeline so a row can never appear with a different age or SLA badge than
* the tile that counted it.
*/
export function worklist(
id: WorklistId,
rows: DecoratedApplication[],
): DecoratedApplication[] {
switch (id) {
case 'dispatch':
// Oldest first: dispatch is the one queue worked strictly by age.
return rows
.filter((row) => row.officerId === null && row.stage === 'intake')
.sort((a, b) => b.ageDays - a.ageDays);
case 'sla':
return rows
.filter(
(row) => row.sla.state === 'breached' || row.sla.state === 'warning',
)
.sort((a, b) => b.sla.ratio - a.sla.ratio || b.ageDays - a.ageDays);
case 'applicant':
return rows
.filter((row) => row.stage === 'applicant')
.sort((a, b) => b.ageDays - a.ageDays);
case 'issue':
return rows
.filter((row) => row.stage === 'issuance')
.sort((a, b) => b.ageDays - a.ageDays);
}
}

View File

@@ -0,0 +1,380 @@
import {
Badge,
Box,
Card,
Center,
Group,
RingProgress,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from '@mantine/core';
import {
IconAlertTriangle,
IconChartBar,
IconHourglass,
IconShieldCheck,
IconTrendingUp,
} from '@tabler/icons-react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
ComposedChart,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { TFunction } from 'i18next';
import { STATUS_TONE_COLOR } from '@ema-platform/shared';
import {
AXIS_STYLE,
ChartCard,
GRID_COLOR,
TOOLTIP_BOX_STYLE,
} from '../../../../charts/ChartCard';
import type {
AgeBucket,
IntakeCohort,
PipelineTotals,
} from '../../logistics-metrics';
/**
* Series colours.
*
* Hard-coded hexes rather than CSS variables because Recharts paints into SVG
* attributes that never resolve a `var()`, which is why the operations
* dashboard does the same. They are the Mantine ramp values for the tones each
* series carries: issued reads success-green, rejected danger-red, still-open
* neutral-grey.
*/
const SERIES = {
issued: '#12b886',
rejected: '#fa5252',
open: '#adb5bd',
submitted: '#228be6',
} as const;
/** Age-band tone → the same hex the badge for that tone would paint. */
const BUCKET_FILL: Record<AgeBucket['tone'], string> = {
neutral: '#868e96',
info: '#228be6',
warning: '#fab005',
danger: '#fa5252',
};
export function ageBucketLabel(t: TFunction, bucket: AgeBucket): string {
return t(`logisticsHead.ageBands.${bucket.id}`, {
defaultValue:
bucket.id === '30+' ? 'Over 30 days' : `${bucket.id.replace('-', '')} days`,
});
}
interface IntakeChartProps {
cohorts: IntakeCohort[];
periodLabel: string;
t: TFunction;
}
/**
* Intake by month, split by how each month's applications ended up.
*
* A cohort chart, not a decisions-per-month one. The history behind it is
* selected on submission date, so plotting decisions on their own dates would
* draw a curve that falls away at the recent end for reasons that have nothing
* to do with the department's output. "Of the forty we took in March, thirty
* are issued and four are still open" is both honest about the data and the
* question a head is actually asking.
*/
export function IntakeCohortChart({ cohorts, periodLabel, t }: IntakeChartProps) {
const empty = cohorts.every((cohort) => cohort.submitted === 0);
// The month in progress is drawn at reduced opacity and asterisked, because
// it is always short of a full month's intake and otherwise reads as a
// collapse in demand to anyone looking before the month is out.
const data = cohorts.map((cohort) => ({
...cohort,
label: cohort.isCurrent ? `${cohort.label}*` : cohort.label,
}));
const opacity = (cohort: IntakeCohort) => (cohort.isCurrent ? 0.45 : 1);
return (
<ChartCard
title={t('logisticsHead.charts.intake.title', 'Intake by month')}
subtitle={t(
'logisticsHead.charts.intake.subtitle',
'Applications received each month, and where each months intake stands today',
)}
icon={IconTrendingUp}
badge={
<Badge variant="light" color="blue" size="sm">
{periodLabel}
</Badge>
}
empty={empty}
emptyText={t(
'logisticsHead.charts.intake.empty',
'No logistics applications were filed in this window.',
)}
footnote={t(
'logisticsHead.charts.intake.partialMonth',
'* The current month is still in progress, so its bar covers part of a month.',
)}
>
<ComposedChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
<XAxis dataKey="label" tick={AXIS_STYLE} tickLine={false} />
<YAxis tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_BOX_STYLE} cursor={{ fillOpacity: 0.06 }} />
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{ fontSize: 12, paddingBottom: 8 }}
/>
<Bar
dataKey="issued"
stackId="cohort"
name={t('logisticsHead.charts.intake.issued', 'Issued')}
fill={SERIES.issued}
maxBarSize={38}
>
{data.map((cohort) => (
<Cell key={cohort.key} fillOpacity={opacity(cohort)} />
))}
</Bar>
<Bar
dataKey="rejected"
stackId="cohort"
name={t('logisticsHead.charts.intake.rejected', 'Rejected')}
fill={SERIES.rejected}
maxBarSize={38}
>
{data.map((cohort) => (
<Cell key={cohort.key} fillOpacity={opacity(cohort)} />
))}
</Bar>
<Bar
dataKey="open"
stackId="cohort"
name={t('logisticsHead.charts.intake.open', 'Still open')}
fill={SERIES.open}
radius={[4, 4, 0, 0]}
maxBarSize={38}
>
{data.map((cohort) => (
<Cell key={cohort.key} fillOpacity={opacity(cohort)} />
))}
</Bar>
{/* The total is already the height of the stack; the line is there so
the trend is readable without adding the segments up by eye. */}
<Line
type="monotone"
dataKey="submitted"
name={t('logisticsHead.charts.intake.submitted', 'Received')}
stroke={SERIES.submitted}
strokeWidth={2.5}
dot={{ r: 3 }}
/>
</ComposedChart>
</ChartCard>
);
}
interface AgeProfileChartProps {
buckets: AgeBucket[];
medianDays: number;
t: TFunction;
}
/** How long the open pipeline has been open. */
export function AgeProfileChart({ buckets, medianDays, t }: AgeProfileChartProps) {
const data = buckets.map((bucket) => ({
...bucket,
label: ageBucketLabel(t, bucket),
}));
const empty = buckets.every((bucket) => bucket.count === 0);
return (
<ChartCard
title={t('logisticsHead.charts.ageing.title', 'Ageing profile')}
subtitle={t(
'logisticsHead.charts.ageing.subtitle',
'How long the open pipeline has been waiting, by band',
)}
icon={IconHourglass}
color="orange"
badge={
<Badge variant="light" color="gray" size="sm">
{t('logisticsHead.charts.ageing.median', {
days: medianDays,
defaultValue: 'median {{days}}d',
})}
</Badge>
}
empty={empty}
emptyText={t(
'logisticsHead.charts.ageing.empty',
'Nothing is open, so there is nothing ageing.',
)}
>
<BarChart data={data} layout="vertical" margin={{ top: 5, right: 24, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} horizontal={false} />
<XAxis type="number" tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
<YAxis
type="category"
dataKey="label"
width={96}
tick={AXIS_STYLE}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP_BOX_STYLE}
cursor={{ fillOpacity: 0.06 }}
formatter={(value) => [
value,
t('logisticsHead.charts.ageing.tooltip', 'Open applications'),
]}
/>
<Bar dataKey="count" radius={[0, 6, 6, 0]} maxBarSize={22}>
{data.map((bucket) => (
<Cell key={bucket.id} fill={BUCKET_FILL[bucket.tone]} />
))}
</Bar>
</BarChart>
</ChartCard>
);
}
interface SlaHealthCardProps {
totals: PipelineTotals;
t: TFunction;
}
/**
* SLA health, against the department's own open work.
*
* Compliance is measured over the applications that have a turnaround target
* configured, not over everything: a licence type with no `slaHours` has no
* target to miss, and counting it as compliant would flatter the number while
* counting it as breached would invent a failure. The count of tracked rows is
* shown alongside so the denominator is never a mystery.
*/
export function SlaHealthCard({ totals, t }: SlaHealthCardProps) {
const rate = totals.slaCompliance;
const tone = rate >= 90 ? 'teal' : rate >= 75 ? 'yellow' : 'red';
const untracked = totals.open - totals.slaTracked;
const tile = (
label: string,
value: number,
hint: string,
color: string,
Icon: typeof IconShieldCheck,
) => (
<Box
p="sm"
style={{
borderRadius: 8,
backgroundColor: `var(--mantine-color-${color}-light)`,
}}
>
<Group gap="xs" wrap="nowrap">
<Icon size={15} color={`var(--mantine-color-${color}-filled)`} />
<Text size="xs" fw={600} c={color}>
{label}
</Text>
</Group>
<Text fz={22} fw={800} c={color} mt={4}>
{value}
</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Box>
);
return (
<Card withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size={32} radius="md" variant="light" color={tone}>
<IconShieldCheck size={18} />
</ThemeIcon>
<div>
<Text fw={600} size="sm" lh={1.3}>
{t('logisticsHead.charts.sla.title', 'Turnaround health')}
</Text>
<Text size="xs" c="dimmed">
{t('logisticsHead.charts.sla.subtitle', {
count: totals.slaTracked,
defaultValue: 'Measured over {{count}} open applications with a target',
})}
</Text>
</div>
</Group>
</Group>
<Center py="xs">
<RingProgress
size={168}
thickness={16}
roundCaps
sections={[
{ value: rate, color: tone },
// The remainder is a track, not a value. A fixed `gray.2` reads as
// a bright white arc in the dark theme; the border token follows
// the colour scheme.
{ value: 100 - rate, color: 'var(--mantine-color-default-border)' },
]}
label={
<Center>
<Stack align="center" gap={0}>
<Text fz={26} fw={800} lh={1}>
{rate}%
</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} mt={4}>
{t('logisticsHead.charts.sla.ring', 'Within target')}
</Text>
</Stack>
</Center>
}
/>
</Center>
<SimpleGrid cols={2} spacing="sm" mt="sm">
{tile(
t('logisticsHead.charts.sla.overdue', 'Overdue'),
totals.overdue,
t('logisticsHead.charts.sla.overdueHint', 'Past their target'),
totals.overdue > 0 ? STATUS_TONE_COLOR.danger : 'gray',
IconAlertTriangle,
)}
{tile(
t('logisticsHead.charts.sla.atRisk', 'At risk'),
totals.atRisk,
t('logisticsHead.charts.sla.atRiskHint', 'Past 70% of the window'),
totals.atRisk > 0 ? STATUS_TONE_COLOR.warning : 'gray',
IconHourglass,
)}
</SimpleGrid>
{untracked > 0 && (
<Group gap={6} mt="sm" wrap="nowrap">
<IconChartBar size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
{t('logisticsHead.charts.sla.untracked', {
count: untracked,
defaultValue:
'{{count}} open applications are of a type with no turnaround target set, so they are excluded from this figure.',
})}
</Text>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,330 @@
import {
Alert,
Badge,
Box,
Card,
Group,
SimpleGrid,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCash,
IconChevronRight,
IconClipboardCheck,
IconCertificate,
IconInbox,
IconMapPin,
IconPlayerPause,
IconSearch,
IconUserExclamation,
type Icon,
} from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { LogisticsStage, StageLoad } from '../../logistics-metrics';
/**
* Where each stage's work sits and what it is called.
*
* Tone, not colour: "applicant" is the same orange as every other
* waiting-on-someone-else state in the platform, and "hold" the same grey.
*/
const STAGE_META: Record<
LogisticsStage,
{ icon: Icon; color: string; label: string; hint: string }
> = {
intake: {
icon: IconInbox,
color: 'blue',
label: 'Intake',
hint: 'Filed and waiting to be handed to an officer',
},
review: {
icon: IconSearch,
color: 'indigo',
label: 'Review',
hint: 'An officer is checking the file and its documents',
},
evaluation: {
icon: IconClipboardCheck,
color: 'violet',
label: 'Evaluation',
hint: 'Capital, staffing and eligibility being assessed',
},
inspection: {
icon: IconMapPin,
color: 'cyan',
label: 'Inspection',
hint: 'Premises visit booked, conducted or reported',
},
applicant: {
icon: IconUserExclamation,
color: 'orange',
label: 'With applicant',
hint: 'Corrections requested — nothing moves until they respond',
},
payment: {
icon: IconCash,
color: 'yellow',
label: 'Fee',
hint: 'Approved, waiting for the licence fee to settle',
},
issuance: {
icon: IconCertificate,
color: 'teal',
label: 'Issuance',
hint: 'Paid and confirmed — only the certificate is left',
},
hold: {
icon: IconPlayerPause,
color: 'gray',
label: 'On hold',
hint: 'Parked by an officer, outside the normal flow',
},
};
export function stageLabel(t: TFunction, stage: LogisticsStage): string {
return t(`logisticsHead.stages.${stage}.label`, STAGE_META[stage].label);
}
interface PipelineBoardProps {
stages: StageLoad[];
bottleneck?: StageLoad;
onOpenStage: (stage: LogisticsStage) => void;
t: TFunction;
}
/**
* `hold` is not a step in the flow — it is a side pocket an officer parks a
* file in, reachable from anywhere and leading back to wherever it came from.
* Drawing it as the eighth card in a left-to-right strip both misrepresented
* it and pushed the strip into a horizontal scrollbar, which hid the last
* stages behind a gesture. It gets its own slot beside the flow instead.
*/
const FLOW_STAGES: LogisticsStage[] = [
'intake',
'review',
'evaluation',
'inspection',
'applicant',
'payment',
'issuance',
];
/**
* The department's work, laid out in the order it actually flows.
*
* The point of the strip is not the totals — the tiles above already carry
* those — but where the pipeline is thick and where it is slow, which is the
* one question a head can act on by moving people. Median age is shown beside
* every count for exactly that reason: a tall column of same-day files is a
* busy stage, not a blocked one.
*/
export function PipelineBoard({
stages,
bottleneck,
onOpenStage,
t,
}: PipelineBoardProps) {
const flow = stages.filter((stage) => FLOW_STAGES.includes(stage.stage));
const held = stages.find((stage) => stage.stage === 'hold');
const busiest = Math.max(1, ...stages.map((stage) => stage.count));
return (
<Card withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="xs">
<Box>
<Text fw={600} size="sm">
{t('logisticsHead.board.title', 'Pipeline by stage')}
</Text>
<Text size="xs" c="dimmed">
{t(
'logisticsHead.board.subtitle',
'Open applications in the order they move through the department. Select a stage to open it in the queue.',
)}
</Text>
</Box>
<Badge variant="light" color="gray" size="sm">
{t('logisticsHead.board.openCount', {
count: stages.reduce((sum, stage) => sum + stage.count, 0),
defaultValue: '{{count}} open',
})}
</Badge>
</Group>
{bottleneck && (
<Alert
variant="light"
color="orange"
radius="md"
mb="md"
icon={<IconAlertTriangle size={18} />}
title={t('logisticsHead.board.bottleneckTitle', 'Slowest stage')}
>
{t('logisticsHead.board.bottleneckBody', {
stage: stageLabel(t, bottleneck.stage),
days: bottleneck.medianDays,
count: bottleneck.count,
defaultValue:
'{{count}} applications are sitting in the {{stage}} stage, half of them for {{days}} days or more. This is where the department is losing the most time.',
})}
</Alert>
)}
<SimpleGrid
// A wrapping grid, not a scrolling row: at anything below a very wide
// viewport the strip needed a horizontal scrollbar, and a stage that
// has to be scrolled into view is a stage a department head does not
// know is backed up.
cols={{ base: 2, sm: 3, md: 4, xl: 7 }}
spacing="sm"
>
{flow.map((stage) => {
const meta = STAGE_META[stage.stage];
const StageIcon = meta.icon;
const empty = stage.count === 0;
return (
<Tooltip
key={stage.stage}
withArrow
multiline
w={230}
label={t(`logisticsHead.stages.${stage.stage}.hint`, meta.hint)}
>
<UnstyledButton
onClick={() => onOpenStage(stage.stage)}
aria-label={`${stageLabel(t, stage.stage)}: ${stage.count}`}
style={{ display: 'block', height: '100%' }}
>
<Card
withBorder
radius="md"
p="sm"
h="100%"
style={{
opacity: empty ? 0.6 : 1,
borderColor: stage.overdue
? 'var(--mantine-color-red-outline)'
: undefined,
}}
>
<Group justify="space-between" wrap="nowrap" mb={6}>
<ThemeIcon
size={26}
radius="sm"
variant="light"
color={meta.color}
>
<StageIcon size={15} stroke={1.8} />
</ThemeIcon>
<IconChevronRight
size={13}
color="var(--mantine-color-dimmed)"
/>
</Group>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.3}>
{stageLabel(t, stage.stage)}
</Text>
<Group gap={6} align="baseline" mt={2}>
<Text fz={26} fw={800} lh={1.1}>
{stage.count}
</Text>
{stage.overdue > 0 && (
<Badge size="xs" color="red" variant="light">
{t('logisticsHead.board.lateChip', {
count: stage.overdue,
defaultValue: '{{count}} late',
})}
</Badge>
)}
</Group>
{/* A share-of-pipeline bar: the numbers alone make it hard
to see at a glance which stage is carrying the load. */}
<Box
mt={8}
mb={6}
style={{
height: 4,
borderRadius: 2,
backgroundColor: 'var(--mantine-color-default-border)',
overflow: 'hidden',
}}
>
<Box
style={{
width: `${Math.round((stage.count / busiest) * 100)}%`,
height: '100%',
backgroundColor: `var(--mantine-color-${meta.color}-filled)`,
}}
/>
</Box>
<Text size="xs" c="dimmed">
{empty
? t('logisticsHead.board.clear', 'Clear')
: t('logisticsHead.board.medianAge', {
days: stage.medianDays,
defaultValue: 'median {{days}}d · oldest {{oldest}}d',
oldest: stage.oldestDays,
})}
</Text>
</Card>
</UnstyledButton>
</Tooltip>
);
})}
</SimpleGrid>
{held && held.count > 0 && (
<UnstyledButton
onClick={() => onOpenStage('hold')}
mt="sm"
w="100%"
aria-label={`${stageLabel(t, 'hold')}: ${held.count}`}
>
<Card withBorder radius="md" p="sm" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={26} radius="sm" variant="light" color="gray">
<IconPlayerPause size={15} stroke={1.8} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{t('logisticsHead.board.heldTitle', {
count: held.count,
defaultValue: '{{count}} parked on hold',
})}
</Text>
<Text size="xs" c="dimmed">
{t('logisticsHead.board.heldHint', {
days: held.medianDays,
defaultValue:
'Outside the flow above — median {{days}}d parked. Not counted as a stage.',
})}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
{held.overdue > 0 && (
<Badge size="sm" color="red" variant="light">
{t('logisticsHead.board.lateChip', {
count: held.overdue,
defaultValue: '{{count}} late',
})}
</Badge>
)}
<IconChevronRight size={14} color="var(--mantine-color-dimmed)" />
</Group>
</Group>
</Card>
</UnstyledButton>
)}
</Card>
);
}

View File

@@ -0,0 +1,186 @@
import {
Anchor,
Badge,
Card,
Divider,
Group,
Stack,
Text,
ThemeIcon,
} from '@mantine/core';
import { IconCalendarRepeat, IconChevronRight } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import { localized, type IssuedLicense } from '@ema-platform/api';
import { daysUntilExpiry, type RenewalRadar } from '../../logistics-metrics';
import { RadarRow } from './WorkloadPanels';
interface RenewalPanelProps {
radar: RenewalRadar;
now: number;
locale: string;
/**
* Set when `/licenses` returned fewer rows than it says exist. The endpoint
* takes no paging parameters, so the panel cannot fetch the rest — it says
* so rather than presenting a partial count as the register's total.
*/
partial?: boolean;
/** The register has no shareable filter of its own, so this only opens it. */
onOpenRegister: () => void;
t: TFunction;
}
/**
* What the department has to renew, and when.
*
* The only forward-looking panel on the page. Everything else counts work that
* has already arrived; this counts work that is going to. A head who can see
* that eleven freight-forwarder licences lapse inside a month can staff for it
* before the applications land, which is the whole reason the licence register
* carries an expiry date.
*/
export function RenewalPanel({
radar,
now,
locale,
partial,
onOpenRegister,
t,
}: RenewalPanelProps) {
const expiryTone = (days: number) =>
days <= 14 ? 'red' : days <= 30 ? 'orange' : days <= 60 ? 'yellow' : 'gray';
const holderName = (licence: IssuedLicense) =>
licence.companyName ?? licence.certificateNumber;
return (
<Card withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size={32} radius="md" variant="light" color="grape">
<IconCalendarRepeat size={18} />
</ThemeIcon>
<div>
<Text fw={600} size="sm" lh={1.3}>
{t('logisticsHead.renewals.title', 'Renewal outlook')}
</Text>
<Text size="xs" c="dimmed">
{t(
'logisticsHead.renewals.subtitle',
'Live operator licences approaching expiry',
)}
</Text>
</div>
</Group>
<Badge variant="light" color="grape" size="sm">
{t('logisticsHead.renewals.active', {
count: radar.active,
defaultValue: '{{count}} active',
})}
</Badge>
</Group>
<Stack gap={2}>
<RadarRow
label={t('logisticsHead.renewals.within30', 'Expiring within 30 days')}
value={radar.within30}
color="red"
onClick={onOpenRegister}
/>
<RadarRow
label={t('logisticsHead.renewals.within60', 'Expiring in 3160 days')}
value={radar.within60}
color="orange"
onClick={onOpenRegister}
/>
<RadarRow
label={t('logisticsHead.renewals.within90', 'Expiring in 6190 days')}
value={radar.within90}
color="yellow"
onClick={onOpenRegister}
/>
<RadarRow
label={t('logisticsHead.renewals.expired', 'Already lapsed')}
value={radar.expired}
color="gray"
onClick={onOpenRegister}
/>
{radar.suspended > 0 && (
<RadarRow
label={t('logisticsHead.renewals.suspended', 'Suspended')}
value={radar.suspended}
// Not "dark": its filled value is near-black, which disappears
// against the dark theme's card background.
color="grape"
onClick={onOpenRegister}
/>
)}
</Stack>
<Divider my="sm" />
<Text size="xs" c="dimmed" tt="uppercase" fw={700} mb="xs">
{t('logisticsHead.renewals.next', 'Next to expire')}
</Text>
{radar.upcoming.length === 0 ? (
<Text size="sm" c="dimmed" py="xs">
{t(
'logisticsHead.renewals.none',
'No live operator licence expires in the next 90 days.',
)}
</Text>
) : (
<Stack gap="xs">
{radar.upcoming.map((licence) => {
const days = daysUntilExpiry(licence, now);
return (
<Group key={licence.id} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Anchor
size="sm"
lineClamp={1}
fw={500}
onClick={onOpenRegister}
>
{holderName(licence)}
</Anchor>
<Text size="xs" c="dimmed" lineClamp={1}>
{localized(licence.licenseType?.name, locale) ||
licence.certificateNumber}
</Text>
</Stack>
<Badge size="sm" variant="light" color={expiryTone(days)}>
{t('logisticsHead.renewals.inDays', {
count: days,
defaultValue: '{{count}}d',
})}
</Badge>
</Group>
);
})}
</Stack>
)}
{partial && (
<Text size="xs" c="dimmed" mt="sm">
{t(
'logisticsHead.renewals.partial',
'The register returned only part of its rows, so these counts cover what was loaded rather than every issued licence.',
)}
</Text>
)}
<Anchor
size="sm"
mt="md"
onClick={onOpenRegister}
// inline-flex keeps the chevron beside the last word; as a plain
// inline node it wrapped onto a line of its own in this narrow column.
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
{t('logisticsHead.renewals.openRegister', 'Open the licence register')}
<IconChevronRight size={12} />
</Anchor>
</Card>
);
}

View File

@@ -0,0 +1,443 @@
import type { ReactNode } from 'react';
import {
Badge,
Box,
Card,
Group,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { IconInbox, IconLicense, IconUsers } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import { localized } from '@ema-platform/api';
import { EmptyState } from '@ema-platform/ui';
import { teamLoad, type OfficerLoad, type TypeLoad } from '../../logistics-metrics';
function PanelHeader({
icon: PanelIcon,
color,
title,
subtitle,
right,
}: {
icon: typeof IconUsers;
color: string;
title: string;
subtitle: string;
right?: ReactNode;
}) {
return (
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size={32} radius="md" variant="light" color={color}>
<PanelIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} size="sm" lh={1.3}>
{title}
</Text>
<Text size="xs" c="dimmed">
{subtitle}
</Text>
</div>
</Group>
{right}
</Group>
);
}
interface OfficerWorkloadCardProps {
loads: OfficerLoad[];
/** Null selects the unassigned pool. */
onSelect: (officerId: string | null) => void;
t: TFunction;
}
/**
* Who is carrying what, and who is in trouble.
*
* A table rather than a chart: a head reading this is about to move a specific
* file to a specific person, and that decision needs names and exact counts,
* not a bar they have to estimate from. The bar is still there, inside each
* row, because the split between on-track, at-risk and overdue is the part
* that reads faster as a shape than as three numbers.
*/
export function OfficerWorkloadCard({
loads,
onSelect,
t,
}: OfficerWorkloadCardProps) {
// Bars are scaled against the heaviest officer so the rows are comparable to
// each other; the median is drawn as a tick across them so "heavy" has a
// reference rather than being whatever the worst case happens to be. There
// is no configured work-in-progress norm in the data model to use instead,
// and inventing one would be worse than using the team's own shape.
const { median, max: heaviest } = teamLoad(loads);
const medianOffset = Math.round((median / heaviest) * 100);
return (
<Card withBorder radius="lg" p="lg" h="100%">
<PanelHeader
icon={IconUsers}
color="indigo"
title={t('logisticsHead.officers.title', 'Officer workload')}
subtitle={t(
'logisticsHead.officers.subtitle',
'Open files per officer, worst SLA position first',
)}
right={
<Badge variant="light" color="gray" size="sm">
{t('logisticsHead.officers.count', {
count: loads.filter((load) => load.officerId !== null).length,
defaultValue: '{{count}} officers',
})}
</Badge>
}
/>
{loads.length === 0 ? (
<EmptyState
title={t('logisticsHead.officers.emptyTitle', 'Nothing is in flight')}
description={t(
'logisticsHead.officers.emptyBody',
'No open logistics application is assigned to anyone right now.',
)}
icon={IconUsers}
/>
) : (
<Table.ScrollContainer minWidth={460}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('logisticsHead.officers.officer', 'Officer')}</Table.Th>
<Table.Th style={{ width: '38%' }}>
<Group gap={6} wrap="nowrap">
<span>{t('logisticsHead.officers.load', 'Load')}</span>
{median > 0 && (
<Text span size="xs" c="dimmed" fw={400}>
{t('logisticsHead.officers.medianTick', {
count: median,
defaultValue: '· team median {{count}}',
})}
</Text>
)}
</Group>
</Table.Th>
<Table.Th ta="right">
{t('logisticsHead.officers.active', 'Open')}
</Table.Th>
<Table.Th ta="right">
{t('logisticsHead.officers.oldest', 'Oldest')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loads.map((load) => (
<Table.Tr
key={load.officerId ?? 'unassigned'}
style={{ cursor: 'pointer' }}
onClick={() => onSelect(load.officerId)}
>
<Table.Td>
<Group gap={8} wrap="nowrap">
{load.officerId === null && (
<ThemeIcon size={20} radius="sm" variant="light" color="gray">
<IconInbox size={12} />
</ThemeIcon>
)}
<Text size="sm" fw={load.officerId === null ? 600 : 500} lineClamp={1}>
{load.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Tooltip
withArrow
label={t('logisticsHead.officers.loadTooltip', {
onTrack: load.onTrack,
atRisk: load.atRisk,
overdue: load.overdue,
defaultValue:
'{{onTrack}} on track · {{atRisk}} at risk · {{overdue}} overdue',
})}
>
<Box style={{ position: 'relative' }}>
{/* The team median, as a tick every row shares. */}
{median > 0 && (
<Box
aria-hidden
style={{
position: 'absolute',
left: `${medianOffset}%`,
top: -2,
bottom: -2,
width: 2,
borderRadius: 1,
backgroundColor: 'var(--mantine-color-dimmed)',
opacity: 0.55,
// Above the bar, not behind it: an officer at or
// over the median has a bar that reaches the
// tick, and that is exactly the row where the
// reference needs to be visible.
zIndex: 2,
}}
/>
)}
{/*
Three flex segments rather than Mantine's
`Progress.Section`, which measured zero width inside
this table cell and painted the whole bar as an empty
track — the split between on-track, at-risk and
overdue is the entire point of the column. Growing
each segment by its own count also removes the
percentage arithmetic, so the parts cannot fail to
add up to the whole.
*/}
<Box
role="img"
aria-label={t('logisticsHead.officers.loadTooltip', {
onTrack: load.onTrack,
atRisk: load.atRisk,
overdue: load.overdue,
defaultValue:
'{{onTrack}} on track · {{atRisk}} at risk · {{overdue}} overdue',
})}
style={{
display: 'flex',
height: 12,
borderRadius: 4,
overflow: 'hidden',
backgroundColor: 'var(--mantine-color-default-border)',
// Scaled against the busiest officer so the rows
// are comparable to each other rather than each
// filling its own width.
width: `${Math.round((load.active / heaviest) * 100)}%`,
}}
>
{(
[
[load.onTrack, 'teal'],
[load.atRisk, 'yellow'],
[load.overdue, 'red'],
] as const
).map(([count, color]) =>
count > 0 ? (
<Box
key={color}
style={{
flex: `${count} 0 0`,
backgroundColor: `var(--mantine-color-${color}-filled)`,
}}
/>
) : null,
)}
</Box>
</Box>
</Tooltip>
</Table.Td>
<Table.Td ta="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Text size="sm" fw={600}>
{load.active}
</Text>
{load.overdue > 0 && (
<Badge size="xs" color="red" variant="light">
{load.overdue}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{t('logisticsHead.columns.days', {
count: load.oldestDays,
defaultValue: '{{count}}d',
})}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
);
}
interface TypePerformanceCardProps {
loads: TypeLoad[];
locale: string;
onSelect: (typeKey: string) => void;
t: TFunction;
}
/** Open work per licence type, against each type's own turnaround target. */
export function TypePerformanceCard({
loads,
locale,
onSelect,
t,
}: TypePerformanceCardProps) {
return (
<Card withBorder radius="lg" p="lg" h="100%">
<PanelHeader
icon={IconLicense}
color="teal"
title={t('logisticsHead.types.title', 'By licence type')}
subtitle={t(
'logisticsHead.types.subtitle',
'Open work per operator licence, measured against that types own target',
)}
/>
{loads.length === 0 ? (
<EmptyState
title={t('logisticsHead.types.emptyTitle', 'No open applications')}
description={t(
'logisticsHead.types.emptyBody',
'Every operator licence type is clear for the current filter.',
)}
icon={IconLicense}
/>
) : (
<Table.ScrollContainer minWidth={560}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('logisticsHead.types.type', 'Licence type')}</Table.Th>
<Table.Th ta="right">{t('logisticsHead.types.open', 'Open')}</Table.Th>
<Table.Th ta="right">
{t('logisticsHead.types.unassigned', 'Unassigned')}
</Table.Th>
<Table.Th ta="right">{t('logisticsHead.types.late', 'Late')}</Table.Th>
<Table.Th ta="right">
{t('logisticsHead.types.median', 'Median age')}
</Table.Th>
<Table.Th ta="right">{t('logisticsHead.types.target', 'Target')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loads.map((load) => (
<Table.Tr
key={load.type.id}
style={{ cursor: 'pointer' }}
onClick={() => onSelect(load.type.key)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={500} lineClamp={1}>
{localized(load.type.name, locale) || load.type.key}
</Text>
{load.type.inspectionRequired && (
<Text size="xs" c="dimmed">
{t('logisticsHead.types.inspected', 'Inspection required')}
</Text>
)}
</Stack>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
{load.open}
</Text>
</Table.Td>
<Table.Td ta="right">
{load.unassigned > 0 ? (
<Badge size="sm" variant="light" color="blue">
{load.unassigned}
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td ta="right">
{load.overdue > 0 ? (
<Badge size="sm" variant="light" color="red">
{load.overdue}
</Badge>
) : load.atRisk > 0 ? (
<Badge size="sm" variant="light" color="yellow">
{t('logisticsHead.types.atRisk', {
count: load.atRisk,
defaultValue: '{{count}} at risk',
})}
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{t('logisticsHead.columns.days', {
count: load.medianDays,
defaultValue: '{{count}}d',
})}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{load.type.slaHours
? t('logisticsHead.types.targetDays', {
count: Math.round(load.type.slaHours / 24),
defaultValue: '{{count}}d',
})
: t('logisticsHead.types.noTarget', 'Not tracked')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
);
}
/** A count that is also a link, used by the renewal radar's band row. */
export function RadarRow({
label,
value,
color,
onClick,
}: {
label: string;
value: number;
color: string;
onClick?: () => void;
}) {
const body = (
<Group justify="space-between" wrap="nowrap" py={4}>
<Group gap={8} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: `var(--mantine-color-${color}-filled)`,
}}
/>
<Text size="sm">{label}</Text>
</Group>
<Text size="sm" fw={700}>
{value}
</Text>
</Group>
);
return onClick ? (
<UnstyledButton onClick={onClick} w="100%">
{body}
</UnstyledButton>
) : (
body
);
}

View File

@@ -1,36 +1,225 @@
import { Badge, Text } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import { Badge, Button, Group, Stack, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import { IconUserPlus } from '@tabler/icons-react';
import {
STATUS_COLORS,
STATUS_LABELS,
type LicenseApplication,
type LicenseStatus,
applicantOrCompanyName,
localized,
type ApplicationKind,
} from '@ema-platform/api';
import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { dateDisplayer } from '@ema-platform/shared';
import { computeSla } from '../../../license-review/sla';
import type { DecoratedApplication } from '../../logistics-metrics';
export const logisticsHeadDashboardColumns: AdvancedColumn<LicenseApplication>[] =
[
/** A decorated row, given the `id` `AdvancedTable` keys its rows on. */
export type WorklistRow = DecoratedApplication & { id: string };
const KIND_COLOR: Record<ApplicationKind, string> = {
NEW: 'blue',
RENEWAL: 'teal',
REISSUE: 'orange',
};
interface WorklistColumnOptions {
/** Officer id → display name, for the column showing who holds the file. */
officerNames: Map<string, string>;
onOpen: (id: string) => void;
/** Omitted on the lists where dispatch is not the action. */
onAssign?: (row: WorklistRow) => void;
assigning?: boolean;
/** Drops the officer column on the dispatch list, where it is always empty. */
showOfficer?: boolean;
}
/**
* The worklist grid under the dashboard.
*
* Deliberately the queue's vocabulary rather than a second one: the same
* status badge, the same SLA badge from `computeSla`, the same kind chip. A
* head who opens the full queue after triaging here should not have to
* re-learn what anything means, and the SLA in particular must not be able to
* disagree between two screens that are both looking at the same row.
*/
export function worklistColumns(
t: TFunction,
locale: string,
options: WorklistColumnOptions,
): AdvancedColumn<WorklistRow>[] {
const { officerNames, onOpen, onAssign, assigning, showOfficer = true } = options;
const columns: AdvancedColumn<WorklistRow>[] = [
{
header: 'Number',
header: t('queue.number', 'App #'),
label: t('queue.number', 'App #'),
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{row.original.app.applicationNumber}
</Text>
{row.original.app.kind !== 'NEW' && (
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.app.kind]}>
{t(
`queue.kindValues.${row.original.app.kind}`,
row.original.app.kind === 'RENEWAL' ? 'Renewal' : 'Replacement',
)}
</Badge>
)}
</Group>
),
},
{
header: 'Company',
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
header: t('queue.company', 'Company'),
label: t('queue.company', 'Company'),
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm" lineClamp={1}>
{applicantOrCompanyName(row.original.app) ?? '—'}
</Text>
<Text size="xs" c="dimmed">
{row.original.app.tinNumber
? `${t('queue.tin', 'TIN')} ${row.original.app.tinNumber}`
: '—'}
</Text>
</Stack>
),
},
{
header: 'Status',
header: t('queue.typeCol', 'Type'),
label: t('queue.typeCol', 'Type'),
size: 190,
cell: ({ row }) => {
const name = localized(row.original.app.licenseType?.name, locale) || '—';
// One line, with the full name on hover: "Multimodal Transport
// Operator" otherwise wraps to three rows and squeezes the status and
// SLA badges beside it into ellipses.
return (
<Tooltip label={name} withArrow>
<Text size="sm" lineClamp={1}>
{name}
</Text>
</Tooltip>
);
},
},
{
header: t('queue.statusCol', 'Status'),
label: t('queue.statusCol', 'Status'),
size: 150,
cell: ({ row }) => {
const label = t(
`queue.statusValues.${row.original.app.status}`,
STATUS_LABELS[row.original.app.status],
);
return (
<Badge color={STATUS_COLORS[row.original.app.status]} variant="light">
{label}
</Badge>
);
},
},
{
header: t('logisticsHead.columns.age', 'Age'),
label: t('logisticsHead.columns.age', 'Age'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[row.original.status as LicenseStatus]}
<Tooltip
label={
row.original.app.submittedAt
? dateDisplayer(row.original.app.submittedAt, locale)
: t('logisticsHead.columns.neverSubmitted', 'Never submitted')
}
withArrow
>
{STATUS_LABELS[row.original.status as LicenseStatus]}
</Badge>
<Text size="sm" c="dimmed">
{t('logisticsHead.columns.days', {
count: row.original.ageDays,
defaultValue: '{{count}}d',
})}
</Text>
</Tooltip>
),
},
{
header: t('logisticsHead.columns.sla', 'SLA'),
label: t('logisticsHead.columns.sla', 'SLA'),
size: 120,
cell: ({ row }) => {
const sla = computeSla(
row.original.app,
undefined,
locale,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
(key, opts) => t(key, opts as any) as string,
);
// Colour is never the only signal — the label says the same thing.
return (
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
);
},
},
];
if (showOfficer) {
columns.push({
header: t('logisticsHead.columns.officer', 'Officer'),
label: t('logisticsHead.columns.officer', 'Officer'),
cell: ({ row }) =>
row.original.officerId ? (
<Text size="sm">
{officerNames.get(row.original.officerId) ??
`#${row.original.officerId.slice(0, 8)}`}
</Text>
) : (
<Badge size="sm" variant="outline" color="gray">
{t('logisticsHead.unassigned', 'Unassigned')}
</Badge>
),
});
}
columns.push({
header: '',
label: t('queue.actionsColumn', 'Actions'),
align: 'right',
size: 150,
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{onAssign && row.original.officerId === null && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.ASSIGN_APPLICATION]} hideOnly>
<Button
size="xs"
loading={assigning}
leftSection={<IconUserPlus size={14} />}
onClick={(event) => {
// The row itself opens the review workspace; the button must
// not do both.
event.stopPropagation();
onAssign(row.original);
}}
>
{t('queue.assign', 'Assign')}
</Button>
</RequirePermission>
)}
<Button
size="xs"
variant="light"
onClick={(event) => {
event.stopPropagation();
onOpen(row.original.app.id);
}}
>
{t('queue.review', 'Review')}
</Button>
</Group>
),
});
return columns;
}

View File

@@ -0,0 +1,220 @@
import { STATUS_LABELS, localized, type LicenseType } from '@ema-platform/api';
import { dateDisplayer } from '@ema-platform/shared';
import { csvCell } from '../license-review/export';
import type {
AgeBucket,
DecoratedApplication,
IntakeCohort,
OfficerLoad,
PipelineTotals,
RenewalRadar,
StageLoad,
TypeLoad,
} from './logistics-metrics';
/**
* The department's position, as a file.
*
* A dashboard is read on a screen by one person; a monthly report to the
* Director-General is a document. This writes the same figures the screen is
* showing — passed in rather than recomputed, so the file and the page can
* never disagree — through the queue's own CSV escaper.
*/
export interface LogisticsReportInput {
generatedAt: number;
periodLabel: string;
typeLabel: string;
locale: string;
totals: PipelineTotals;
stages: StageLoad[];
ageProfile: AgeBucket[];
officers: OfficerLoad[];
types: TypeLoad[];
cohorts: IntakeCohort[];
renewals: RenewalRadar;
slaCritical: DecoratedApplication[];
awaitingDispatch: DecoratedApplication[];
/** Human labels for the stage and bucket ids, from the caller's i18n. */
stageLabel: (stage: StageLoad['stage']) => string;
bucketLabel: (bucket: AgeBucket) => string;
}
function typeName(type: LicenseType, locale: string): string {
return localized(type.name, locale) || type.key;
}
/** Builds the report body. Separated from the download so it can be inspected. */
export function buildLogisticsReport(input: LogisticsReportInput): string {
const {
generatedAt,
periodLabel,
typeLabel,
locale,
totals,
stages,
officers,
types,
cohorts,
renewals,
slaCritical,
awaitingDispatch,
stageLabel,
bucketLabel,
} = input;
// Cells are numbers as often as strings; `csvCell` stringifies either.
const rows: unknown[][] = [];
const section = (title: string) => {
if (rows.length) rows.push([]);
rows.push([title]);
};
rows.push(['ETHIOPIAN MARITIME AUTHORITY — LOGISTICS DEPARTMENT REPORT']);
rows.push(['Generated', dateDisplayer(new Date(generatedAt), locale)]);
rows.push(['Intake window', periodLabel]);
rows.push(['Licence type', typeLabel]);
section('OPEN PIPELINE');
rows.push(['Metric', 'Value']);
rows.push(['Open applications', totals.open]);
rows.push(['Awaiting dispatch', totals.awaitingDispatch]);
rows.push(['With officers', totals.withOfficers]);
rows.push(['Overdue against SLA', totals.overdue]);
rows.push(['At risk against SLA', totals.atRisk]);
rows.push(['Waiting on applicant', totals.waitingOnApplicant]);
rows.push(['In inspection', totals.inInspection]);
rows.push(['Awaiting payment', totals.awaitingPayment]);
rows.push(['Ready to issue', totals.readyToIssue]);
rows.push(['On hold', totals.onHold]);
rows.push(['SLA-tracked applications', totals.slaTracked]);
rows.push(['SLA compliance (%)', totals.slaCompliance]);
rows.push(['Median age (days)', totals.medianDays]);
rows.push(['Oldest open (days)', totals.oldestDays]);
section('BY STAGE');
rows.push(['Stage', 'Open', 'Overdue', 'Median age (days)', 'Oldest (days)']);
for (const stage of stages) {
rows.push([
stageLabel(stage.stage),
stage.count,
stage.overdue,
stage.medianDays,
stage.oldestDays,
]);
}
section('AGEING PROFILE');
rows.push(['Age band', 'Open applications']);
for (const bucket of input.ageProfile) {
rows.push([bucketLabel(bucket), bucket.count]);
}
section('OFFICER WORKLOAD');
rows.push(['Officer', 'Active', 'Overdue', 'At risk', 'On track', 'Oldest (days)']);
for (const officer of officers) {
rows.push([
officer.name,
officer.active,
officer.overdue,
officer.atRisk,
officer.onTrack,
officer.oldestDays,
]);
}
section('BY LICENCE TYPE');
rows.push([
'Licence type',
'Open',
'Unassigned',
'Overdue',
'At risk',
'Median age (days)',
'SLA target (hours)',
]);
for (const load of types) {
rows.push([
typeName(load.type, locale),
load.open,
load.unassigned,
load.overdue,
load.atRisk,
load.medianDays,
load.type.slaHours ?? 'Not tracked',
]);
}
section('INTAKE BY MONTH (cohort outcome)');
rows.push(['Month', 'Submitted', 'Issued / completed', 'Rejected', 'Still open']);
for (const cohort of cohorts) {
rows.push([
cohort.key,
cohort.submitted,
cohort.issued,
cohort.rejected,
cohort.open,
]);
}
section('RENEWALS');
rows.push(['Band', 'Licences']);
rows.push(['Active', renewals.active]);
rows.push(['Expiring within 30 days', renewals.within30]);
rows.push(['Expiring in 3160 days', renewals.within60]);
rows.push(['Expiring in 6190 days', renewals.within90]);
rows.push(['Expired', renewals.expired]);
rows.push(['Suspended', renewals.suspended]);
const worklistHeader = [
'Application #',
'Company',
'TIN',
'Licence type',
'Status',
'Submitted',
'Age (days)',
'SLA',
];
const worklistRow = (row: DecoratedApplication) => [
row.app.applicationNumber,
row.app.companyName ?? '',
row.app.tinNumber ?? '',
localized(row.app.licenseType?.name, locale) || row.app.licenseTypeId,
STATUS_LABELS[row.app.status] ?? row.app.status,
row.app.submittedAt ? dateDisplayer(row.app.submittedAt, locale) : '',
row.ageDays,
row.sla.label,
];
section('SLA PRIORITY WORKLIST');
rows.push(worklistHeader);
for (const row of slaCritical) rows.push(worklistRow(row));
section('AWAITING DISPATCH');
rows.push(worklistHeader);
for (const row of awaitingDispatch) rows.push(worklistRow(row));
return rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
}
/**
* Writes the report to the user's machine.
*
* The BOM is what makes Excel open Amharic company names as UTF-8 rather than
* mojibake — the same reason the queue's export carries one.
*/
export function downloadLogisticsReport(input: LogisticsReportInput): string {
const filename = `EMA-logistics-department-${new Date(input.generatedAt)
.toISOString()
.slice(0, 10)}.csv`;
const blob = new Blob(['', buildLogisticsReport(input)], {
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);
return filename;
}

View File

@@ -0,0 +1,357 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge, Card, Group, SimpleGrid, Text, Tooltip } from '@mantine/core';
import {
IconAlarm,
IconAnchor,
IconBook2,
IconCalendarStats,
IconClockHour4,
IconCoin,
IconHeartbeat,
IconInbox,
IconSchool,
IconShieldCheck,
IconThumbUp,
IconUsers,
IconVenus,
type Icon,
} from '@tabler/icons-react';
import type { SeafarerReport } from '@ema-platform/api';
import {
DASH,
deltaColor,
formatDelta,
formatMoney,
formatNumber,
formatPercent,
formatSeaTime,
ROUTES,
type Tab,
} from './report-format';
interface TileProps {
icon: Icon;
label: string;
value: string;
/** The second line: what the headline figure is made of. */
detail?: string;
/** Hover text for anything the headline alone would misrepresent. */
hint?: string;
delta?: { text: string; color: string };
color?: string;
/** The screen that owns the full list behind this figure. */
to: string;
}
/**
* One headline figure, and a door.
*
* Every tile links to the queue or register it summarises: a number an officer
* cannot act on from where they read it is a report, and this page is meant
* to be a desk.
*/
function Tile({
icon: TileIcon,
label,
value,
detail,
hint,
delta,
color = 'blue',
to,
}: TileProps) {
const card = (
<Card
component={Link}
to={to}
withBorder
radius="md"
p="md"
style={{ textDecoration: 'none', color: 'inherit' }}
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.3}>
{label}
</Text>
<TileIcon size={18} stroke={1.6} color={`var(--mantine-color-${color}-6)`} />
</Group>
<Group gap="xs" align="baseline" wrap="nowrap">
<Text fz={26} fw={700} lh={1.1}>
{value}
</Text>
{delta && (
<Badge size="sm" variant="light" color={delta.color}>
{delta.text}
</Badge>
)}
</Group>
{detail && (
<Text size="xs" c="dimmed" mt={6} lh={1.4}>
{detail}
</Text>
)}
</Card>
);
return hint ? (
<Tooltip label={hint} multiline w={280} withArrow>
{card}
</Tooltip>
) : (
card
);
}
/**
* The tile each section can draw on, keyed so a section lists the ones it
* wants and the Overview can borrow from every shelf without duplicating the
* wiring.
*/
type TileKey =
| 'onRegister'
| 'inPeriod'
| 'open'
| 'processing'
| 'approvalRate'
| 'workforce'
| 'documents'
| 'certificates'
| 'expiring'
| 'fees'
| 'medical'
| 'seaTime'
| 'eligible';
/**
* Which tiles each tab shows. Two scopes sit side by side and the labels keep
* them apart: the register totals describe the whole population regardless of
* the date filter, while "in period" and the pipeline figures answer to it.
*/
const SECTION_TILES: Record<Tab, TileKey[]> = {
overview: [
'onRegister',
'inPeriod',
'open',
'expiring',
'certificates',
'medical',
'seaTime',
'eligible',
],
registration: [
'onRegister',
'inPeriod',
'open',
'processing',
'approvalRate',
'workforce',
],
documents: ['documents', 'certificates', 'expiring', 'fees'],
medical: ['medical', 'seaTime', 'eligible', 'expiring'],
};
export function KpiTiles({
report,
section,
}: {
report: SeafarerReport;
section: Tab;
}) {
const { t } = useTranslation();
const k = (key: string, opts?: Record<string, unknown>) =>
t(`seafarerAnalytics.tiles.${key}`, opts);
const n = formatNumber;
const {
registry,
pipeline,
demographics,
documents,
certificates,
medical,
seaService,
revenue,
} = report.kpis;
const tiles: Record<TileKey, TileProps> = {
onRegister: {
icon: IconUsers,
label: k('onRegister'),
value: n(registry.approved),
detail: k('onRegisterDetail', {
total: n(registry.total),
rejected: n(registry.rejected),
draft: n(registry.draft),
}),
hint: k('onRegisterHint'),
to: ROUTES.registry,
},
inPeriod: {
icon: IconCalendarStats,
color: 'teal',
label: k('inPeriod'),
value: n(registry.registeredInPeriod),
detail: k('inPeriodDetail', { previous: n(registry.registeredInPreviousPeriod) }),
delta: {
text: formatDelta(registry.changePct),
color: deltaColor(registry.changePct),
},
hint: k('inPeriodHint'),
to: ROUTES.registry,
},
open: {
icon: IconInbox,
color: 'orange',
label: k('open'),
value: n(registry.pending),
detail: k('openDetail', {
biometrics: n(pipeline.awaitingBiometrics),
oldest: n(pipeline.oldestPendingDays),
}),
hint: k('openHint'),
to: ROUTES.registrationQueue,
},
processing: {
icon: IconClockHour4,
color: 'grape',
label: k('processing'),
value:
pipeline.medianProcessingDays === null
? DASH
: n(pipeline.medianProcessingDays, { decimals: 1, suffix: ' d' }),
detail: k('processingDetail', {
mean: n(pipeline.avgProcessingDays, { decimals: 1 }),
decided: n(pipeline.decidedInPeriod),
}),
hint: k('processingHint'),
to: ROUTES.registrationQueue,
},
approvalRate: {
icon: IconThumbUp,
color: 'green',
label: k('approvalRate'),
value: formatPercent(pipeline.approvalRatePct),
detail: k('approvalRateDetail', {
approved: n(registry.approved),
rejected: n(registry.rejected),
filed: n(pipeline.submittedInPeriod),
}),
hint: k('approvalRateHint'),
to: ROUTES.registrationQueue,
},
workforce: {
icon: IconVenus,
color: 'grape',
label: k('workforce'),
value:
demographics.avgAgeYears === null
? DASH
: n(demographics.avgAgeYears, { decimals: 1, suffix: ' yrs' }),
// The coverage count is not decoration: an average over 4 of 3,000
// records is a different claim from an average over all of them.
detail: k('workforceDetail', {
known: n(demographics.ageKnownFor),
total: n(registry.total),
female: formatPercent(demographics.femalePct),
nationalities: n(demographics.nationalities),
}),
hint: k('workforceHint'),
to: ROUTES.registry,
},
documents: {
icon: IconBook2,
color: 'indigo',
label: k('documents'),
value: n(documents.issued),
detail: k('documentsDetail', {
seamanBook: n(documents.seamanBookIssued),
btc: n(documents.btcIssued),
open: n(documents.pending),
}),
hint: k('documentsHint'),
to: ROUTES.seamanBookQueue,
},
certificates: {
icon: IconShieldCheck,
color: 'cyan',
label: k('certificates'),
value: n(certificates.active),
detail: k('certificatesDetail', {
coc: n(certificates.cocActive),
cop: n(certificates.copActive),
endorsement: n(certificates.endorsementActive),
}),
hint: k('certificatesHint', { holders: n(certificates.holders) }),
to: ROUTES.licenceRegister,
},
expiring: {
icon: IconAlarm,
color: 'red',
label: k('expiring'),
value: n(
certificates.expiringIn30 + documents.expiringIn30 + medical.expiringIn30,
),
detail: k('expiringDetail', {
certificates: n(certificates.expiringIn30),
documents: n(documents.expiringIn30),
medicals: n(medical.expiringIn30),
}),
hint: k('expiringHint'),
to: ROUTES.licenceRegister,
},
fees: {
icon: IconCoin,
color: 'yellow',
label: k('fees'),
value: formatMoney(revenue.paid, revenue.currency),
detail: k('feesDetail', {
pending: formatMoney(revenue.pending, revenue.currency),
failed: n(revenue.failedCount),
}),
hint: revenue.mixedCurrency ? k('feesMixedHint') : k('feesHint'),
to: ROUTES.payments,
},
medical: {
icon: IconHeartbeat,
color: 'pink',
label: k('medical'),
value: n(medical.seafarersCovered),
detail: k('medicalDetail', {
lapsed: n(medical.seafarersLapsed),
unfit: n(medical.unfit),
toVerify: n(medical.pendingVerification),
}),
hint: k('medicalHint'),
to: ROUTES.medicalDesk,
},
seaTime: {
icon: IconAnchor,
color: 'blue',
label: k('seaTime'),
value: formatSeaTime(seaService.avgSeaDaysPerSeafarer),
detail: k('seaTimeDetail', {
over12: n(seaService.seafarersOverTwelveMonths),
withAny: n(seaService.seafarersWithService),
}),
hint: k('seaTimeHint'),
to: ROUTES.seaServiceDesk,
},
eligible: {
icon: IconSchool,
color: 'lime',
label: k('eligible'),
value: n(seaService.eligibleUncertified),
detail: k('eligibleDetail'),
hint: k('eligibleHint'),
to: ROUTES.cocQueue,
},
};
return (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{SECTION_TILES[section].map((key) => (
<Tile key={key} {...tiles[key]} />
))}
</SimpleGrid>
);
}

View File

@@ -0,0 +1,396 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Group, SimpleGrid, Text } from '@mantine/core';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { BreakdownItem, SeafarerReport } from '@ema-platform/api';
import {
expiryBands,
formatNumber,
officerLabel,
relabel,
sliceColor,
type Tab,
useBucketTick,
useLabeller,
} from './report-format';
// The same chart setup the vessel report uses, so the two dashboards read as
// one product: one grid style, one tooltip style, one axis style, and a fixed
// height so the rows line up.
const CHART_HEIGHT = 260;
const AXIS = { fontSize: 11, stroke: 'var(--mantine-color-dimmed)' } as const;
const GRID = 'var(--mantine-color-default-border)';
const TOOLTIP_STYLE = {
background: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
fontSize: 12,
} as const;
function ChartCard({
title,
subtitle,
children,
empty,
emptyText,
}: {
title: string;
subtitle?: string;
children: ReactNode;
/** True when there is genuinely nothing to draw — say so, don't draw axes. */
empty?: boolean;
emptyText: string;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text fw={600} size="sm">
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</Group>
{empty ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
{emptyText}
</Text>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
{children as never}
</ResponsiveContainer>
)}
</Card>
);
}
/**
* "12 (7.5%)" for a breakdown tooltip.
*
* The share comes off the payload rather than being recomputed: the API's
* percentage is of the whole, including the slices folded into "Other", and
* dividing by what is on screen would quietly disagree with it.
*/
function countWithShare(value: unknown, entry: unknown): string {
const count = typeof value === 'number' ? value : Number(value ?? 0);
const payload = (entry as { payload?: BreakdownItem } | undefined)?.payload;
const share = payload?.percentage ?? 0;
return `${formatNumber(count)} (${formatNumber(share, { decimals: 1 })}%)`;
}
/** True when every bucket in a zero-filled series is empty. */
const allZero = (values: number[]): boolean =>
values.every((value) => value === 0);
/** A band row: what `expiryBands` yields, and a `BreakdownItem` also fits. */
interface BandRow {
label: string;
count: number;
key?: string;
percentage?: number;
}
interface ChartsProps {
report: SeafarerReport;
section: Tab;
/** IAM user id → display name, for the reviewer chart. Empty when unknown. */
officerNames: Map<string, string>;
}
export function ReportCharts({ report, section, officerNames }: ChartsProps) {
const { t } = useTranslation();
const c = (key: string, opts?: Record<string, unknown>) =>
t(`seafarerAnalytics.charts.${key}`, opts);
const unit = (key: string) => c(`units.${key}`);
const label = useLabeller();
const tick = useBucketTick(report.filters.granularity);
// Recharts types the tooltip label as a ReactNode; only a string is ever a
// bucket key, and anything else is passed through untouched.
const tickLabel = (value: unknown) =>
typeof value === 'string' ? tick(value) : String(value ?? '');
const nothing = c('nothing');
const { timeSeries, breakdowns, kpis } = report;
// ------------------------------------------------------------ primitives
/**
* A ranked breakdown as horizontal bars. Horizontal because the labels are
* departments, ranks, nationalities and flag states — words, which a
* vertical axis can show in full instead of rotating them.
*/
const bars = (
key: string,
title: string,
items: BandRow[],
opts: { subtitle?: string; unitKey?: string; color?: string } = {},
) => (
<ChartCard
key={key}
title={title}
subtitle={opts.subtitle}
empty={items.length === 0 || allZero(items.map((i) => i.count))}
emptyText={nothing}
>
<BarChart data={items} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...AXIS} />
<YAxis type="category" dataKey="label" width={140} {...AXIS} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, _name, entry) => [
// Bands are already disjoint counts with no share to show.
opts.color ? formatNumber(Number(value ?? 0)) : countWithShare(value, entry),
unit(opts.unitKey ?? 'seafarers'),
]}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill={opts.color}>
{!opts.color &&
items.map((item, index) => (
<Cell
key={item.key ?? item.label}
fill={sliceColor(item as BreakdownItem, index)}
/>
))}
</Bar>
</BarChart>
</ChartCard>
);
const donut = (
key: string,
title: string,
items: BreakdownItem[],
subtitle?: string,
) => (
<ChartCard key={key} title={title} subtitle={subtitle} empty={items.length === 0} emptyText={nothing}>
<PieChart>
<Pie
data={items}
dataKey="count"
nameKey="label"
innerRadius="52%"
outerRadius="78%"
paddingAngle={2}
>
{items.map((item, index) => (
<Cell key={item.key} fill={sliceColor(item, index)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name, entry) => [countWithShare(value, entry), name]}
/>
<Legend verticalAlign="bottom" height={36} wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ChartCard>
);
// ------------------------------------------------------------- the charts
const throughput = (
<ChartCard
key="throughput"
title={c('throughput')}
subtitle={c('throughputSub')}
emptyText={nothing}
empty={allZero(
timeSeries.registrations.flatMap((b) => [b.submitted, b.approved, b.rejected]),
)}
>
<BarChart data={timeSeries.registrations}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="submitted" name={c('submitted')} fill="var(--mantine-color-blue-4)" />
{/* Approved and rejected stack: together they are the decisions made
in that bucket, which reads against intake beside it. */}
<Bar dataKey="approved" name={c('approved')} stackId="decided" fill="var(--mantine-color-teal-6)" />
<Bar dataKey="rejected" name={c('rejected')} stackId="decided" fill="var(--mantine-color-red-6)" />
</BarChart>
</ChartCard>
);
const documentsIssued = (
<ChartCard
key="documentsIssued"
title={c('documentsIssued')}
subtitle={c('documentsIssuedSub')}
emptyText={nothing}
empty={allZero(timeSeries.documents.map((b) => b.total))}
>
<AreaChart data={timeSeries.documents}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="seamanBook" name={c('seamanBook')} stackId="docs" stroke="var(--mantine-color-indigo-6)" fill="var(--mantine-color-indigo-3)" />
<Area type="monotone" dataKey="btc" name={c('btc')} stackId="docs" stroke="var(--mantine-color-cyan-6)" fill="var(--mantine-color-cyan-3)" />
</AreaChart>
</ChartCard>
);
const certificatesIssued = (
<ChartCard
key="certificatesIssued"
title={c('certificatesIssued')}
subtitle={c('certificatesIssuedSub')}
emptyText={nothing}
empty={allZero(timeSeries.certificates.map((b) => b.total))}
>
<BarChart data={timeSeries.certificates}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="coc" name={c('coc')} stackId="certs" fill="var(--mantine-color-blue-6)" />
<Bar dataKey="cop" name={c('cop')} stackId="certs" fill="var(--mantine-color-teal-6)" />
<Bar dataKey="endorsement" name={c('endorsement')} stackId="certs" fill="var(--mantine-color-grape-6)" />
</BarChart>
</ChartCard>
);
const verifications = (
<ChartCard
key="verifications"
title={c('verifications')}
subtitle={c('verificationsSub')}
emptyText={nothing}
empty={allZero(timeSeries.verifications.flatMap((b) => [b.medical, b.seaService]))}
>
<LineChart data={timeSeries.verifications}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="medical" name={c('medical')} stroke="var(--mantine-color-pink-6)" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="seaService" name={c('seaService')} stroke="var(--mantine-color-blue-6)" strokeWidth={2} dot={false} />
</LineChart>
</ChartCard>
);
const fees = (
<ChartCard
key="fees"
title={c('fees')}
subtitle={kpis.revenue.currency}
emptyText={nothing}
empty={allZero(timeSeries.revenue.map((b) => b.amount))}
>
<LineChart data={timeSeries.revenue}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Line type="monotone" dataKey="amount" name={c('paid', { currency: kpis.revenue.currency })} stroke="var(--mantine-color-yellow-7)" strokeWidth={2} dot={false} />
</LineChart>
</ChartCard>
);
// Expiry counts arrive cumulative; drawn side by side they have to be
// disjoint or the three bars double-count each other.
const certificateExpiry = bars('certificateExpiry', c('certificateExpiry'), expiryBands(kpis.certificates), {
subtitle: c('disjoint'), unitKey: 'certificates', color: 'var(--mantine-color-orange-6)',
});
const documentExpiry = bars('documentExpiry', c('documentExpiry'), expiryBands(kpis.documents), {
subtitle: c('disjoint'), unitKey: 'documents', color: 'var(--mantine-color-indigo-6)',
});
const medicalExpiry = bars('medicalExpiry', c('medicalExpiry'), expiryBands(kpis.medical), {
subtitle: c('disjoint'), unitKey: 'medicals', color: 'var(--mantine-color-pink-6)',
});
// A single "oldest 47 d" hides the shape; the bands say whether the queue is
// a slow trickle or a wall.
const backlogAge = bars('backlogAge', c('backlogAge'), breakdowns.byPendingAge, {
subtitle: c('backlogAgeSub'), unitKey: 'registrations', color: 'var(--mantine-color-orange-6)',
});
const reviewers = breakdowns.byReviewOfficer.map((item) => ({
...item,
label:
item.key === 'UNASSIGNED'
? c('unassigned')
: (officerNames.get(item.key) ?? officerLabel(item.key)),
}));
const registrationBreakdowns = [
donut('status', c('registrationStatus'), relabel(breakdowns.byRegistrationStatus, label('status'))),
donut('department', c('department'), breakdowns.byDepartment),
donut('tier', c('tier'), relabel(breakdowns.byTier, label('tier')), c('tierSub')),
bars('age', c('ageBands'), breakdowns.byAgeBand, { subtitle: c('ageBandsSub') }),
donut('gender', c('gender'), relabel(breakdowns.byGender, label('gender'))),
bars('nationality', c('nationalities'), breakdowns.byNationality, { subtitle: c('topSlices') }),
bars('reviewers', c('reviewerWorkload'), reviewers, { subtitle: c('reviewerWorkloadSub'), unitKey: 'decisions' }),
];
const documentBreakdowns = [
donut('docKind', c('documentKind'), relabel(breakdowns.byDocumentKind, label('documentKind'))),
bars('docStatus', c('documentStatus'), breakdowns.byDocumentStatus, { unitKey: 'requests' }),
documentExpiry,
donut('certType', c('certificateType'), relabel(breakdowns.byCertificateType, label('certificateType'))),
donut('certStatus', c('certificateStatus'), breakdowns.byCertificateStatus),
bars('certRank', c('certifiedRanks'), breakdowns.byCertificateRank, { subtitle: c('topSlices'), unitKey: 'certificates' }),
certificateExpiry,
bars('certApps', c('certApplications'), breakdowns.byCertApplicationStatus, { subtitle: c('certApplicationsSub'), unitKey: 'applications' }),
];
const medicalBreakdowns = [
donut('fitness', c('medicalFitness'), relabel(breakdowns.byMedicalFitness, label('fitness'))),
bars('issuers', c('medicalIssuers'), breakdowns.byMedicalIssuer, { subtitle: c('topSlices'), unitKey: 'certificates' }),
medicalExpiry,
donut('ssStatus', c('seaServiceVerification'), breakdowns.bySeaServiceStatus),
bars('seaDays', c('seaTimeBands'), breakdowns.bySeaDaysBand, { subtitle: c('seaTimeBandsSub') }),
bars('ssRank', c('seaServiceRanks'), breakdowns.bySeaServiceRank, { subtitle: c('seaServiceRanksSub'), unitKey: 'engagements' }),
bars('vesselTypes', c('vesselTypes'), breakdowns.bySeaServiceVesselType, { unitKey: 'engagements' }),
bars('flags', c('flagStates'), breakdowns.bySeaServiceFlagState, { unitKey: 'engagements' }),
];
// ------------------------------------------------------------- sections
const sections: Record<Tab, { trends: ReactNode[]; slices: ReactNode[] }> = {
overview: {
trends: [throughput, certificatesIssued],
slices: [backlogAge, certificateExpiry, medicalExpiry],
},
registration: { trends: [throughput, backlogAge], slices: registrationBreakdowns },
documents: { trends: [documentsIssued, certificatesIssued, fees], slices: documentBreakdowns },
medical: { trends: [verifications], slices: medicalBreakdowns },
};
const { trends, slices } = sections[section];
return (
<>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{trends}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
{slices}
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,216 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Group,
MultiSelect,
SegmentedControl,
TextInput,
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconDownload, IconSearch, IconX } from '@tabler/icons-react';
import type {
Gender,
RankTier,
ReportGranularity,
SeafarerRegistrationStatus,
SeafarerReport,
SeafarerReportQuery,
} from '@ema-platform/api';
import { optionsFrom } from './report-format';
const STATUSES: SeafarerRegistrationStatus[] = [
'DRAFT',
'AWAITING_BIOMETRICS',
'UNDER_REVIEW',
'SUBMITTED',
'RESUBMIT_REQUIRED',
'APPROVED',
'REJECTED',
];
interface ReportFiltersProps {
query: SeafarerReportQuery;
onChange: (next: SeafarerReportQuery) => void;
/**
* The last successful response. Departments and nationalities are free text
* on the register with no lookup endpoint behind them, so the only honest
* source for the options is what the register actually holds.
*/
report?: SeafarerReport;
onExport: () => void;
exporting: boolean;
}
export function ReportFilters({
query,
onChange,
report,
onExport,
exporting,
}: ReportFiltersProps) {
const { t } = useTranslation();
const f = (key: string) => t(`seafarerAnalytics.filters.${key}`);
const enumLabel = (ns: string, value: string) =>
t(`seafarerAnalytics.${ns}.${value}`, { defaultValue: value });
// The search box is local so typing does not refetch on every keystroke; it
// is pushed up on a debounce.
const [search, setSearch] = useState(query.search ?? '');
useEffect(() => {
setSearch(query.search ?? '');
}, [query.search]);
useEffect(() => {
const current = query.search ?? '';
if (search === current) return;
const timer = setTimeout(
() => onChange({ ...query, search: search.trim() || undefined }),
350,
);
return () => clearTimeout(timer);
}, [search, query, onChange]);
const set = <K extends keyof SeafarerReportQuery>(
key: K,
value: SeafarerReportQuery[K],
) => onChange({ ...query, [key]: value });
// Mantine 8 works in `YYYY-MM-DD` strings here, which is exactly what the
// API wants — no Date round trip, and no timezone to shift the day.
const range: [string | null, string | null] = [
query.from ?? null,
query.to ?? null,
];
const filtered =
Boolean(query.search) ||
Boolean(query.from) ||
Boolean(query.to) ||
[query.status, query.department, query.tier, query.gender, query.nationality].some(
(values) => (values ?? []).length > 0,
);
return (
<Card withBorder radius="md" p="md" mb="md">
<Group align="flex-end" gap="sm" wrap="wrap">
<DatePickerInput
type="range"
label={f('period')}
placeholder={f('periodPlaceholder')}
value={range}
// Both ends before refetching: a half-set range would send `from`
// with no `to` and redraw the charts against a window the user is
// still in the middle of choosing.
onChange={([from, to]) => {
if (from && !to) return;
onChange({ ...query, from: from ?? undefined, to: to ?? undefined });
}}
clearable
w={250}
/>
<SegmentedControl
size="sm"
data={[
{ value: 'DAY', label: f('day') },
{ value: 'WEEK', label: f('week') },
{ value: 'MONTH', label: f('month') },
]}
value={query.granularity ?? 'MONTH'}
onChange={(value) => set('granularity', value as ReportGranularity)}
/>
<MultiSelect
label={f('status')}
placeholder={f('all')}
data={STATUSES.map((value) => ({ value, label: enumLabel('status', value) }))}
value={query.status ?? []}
onChange={(value) => set('status', value as SeafarerRegistrationStatus[])}
clearable
w={210}
/>
<MultiSelect
label={f('department')}
placeholder={f('all')}
data={optionsFrom(report?.breakdowns.byDepartment)}
value={query.department ?? []}
onChange={(value) => set('department', value)}
searchable
clearable
w={180}
/>
<MultiSelect
label={f('tier')}
placeholder={f('all')}
data={(['ABOVE', 'BELOW'] as RankTier[]).map((value) => ({
value,
label: enumLabel('tier', value),
}))}
value={query.tier ?? []}
onChange={(value) => set('tier', value as RankTier[])}
clearable
w={190}
/>
<MultiSelect
label={f('gender')}
placeholder={f('all')}
data={(['MALE', 'FEMALE'] as Gender[]).map((value) => ({
value,
label: enumLabel('gender', value),
}))}
value={query.gender ?? []}
onChange={(value) => set('gender', value as Gender[])}
clearable
w={150}
/>
<MultiSelect
label={f('nationality')}
placeholder={f('all')}
data={optionsFrom(report?.breakdowns.byNationality)}
value={query.nationality ?? []}
onChange={(value) => set('nationality', value)}
searchable
clearable
w={180}
/>
<TextInput
label={f('search')}
placeholder={f('searchPlaceholder')}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
w={260}
/>
<Group gap="xs" ml="auto">
{filtered && (
<Button
variant="subtle"
color="gray"
leftSection={<IconX size={14} />}
onClick={() => onChange({})}
>
{f('clear')}
</Button>
)}
<Button
variant="light"
leftSection={<IconDownload size={16} />}
loading={exporting}
onClick={onExport}
>
{f('export')}
</Button>
</Group>
</Group>
</Card>
);
}

View File

@@ -0,0 +1,382 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge, Card, Group, SimpleGrid, Table, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import type { SeafarerReport } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
import {
expiryUrgency,
FITNESS_COLORS,
formatNumber,
formatSeaTime,
REGISTRATION_STATUS_COLORS,
ROUTES,
type Tab,
useLabeller,
} from './report-format';
/**
* The worklists.
*
* Plain Mantine tables rather than `AdvancedTable`: every one of these is
* already capped server-side by `tableLimit`, so the pagination, search and
* column-picker that component brings would all be controls over a list that
* is only ever ten rows of a much longer one. Each card links out to the screen
* that does own the full list.
*/
function TableCard({
title,
subtitle,
to,
linkLabel,
empty,
emptyText,
head,
children,
}: {
title: string;
subtitle?: string;
to?: string;
linkLabel: string;
empty: boolean;
emptyText: string;
head: string[];
children: ReactNode;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</div>
{to && (
<Text component={Link} to={to} size="xs" c="blue">
{linkLabel}
</Text>
)}
</Group>
{empty ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
{emptyText}
</Text>
) : (
<Table highlightOnHover verticalSpacing="xs" fz="sm">
<Table.Thead>
<Table.Tr>
{head.map((column) => (
<Table.Th key={column}>{column}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{children}</Table.Tbody>
</Table>
)}
</Card>
);
}
/** Name over reference — the reference is how a file is found, not read. */
function Holder({
name,
reference,
unnamed,
}: {
name: string | null;
reference: string | null;
unnamed: string;
}) {
return (
<>
<Text size="sm" fw={500}>
{name ?? unnamed}
</Text>
{reference && (
<Text size="xs" c="dimmed">
{reference}
</Text>
)}
</>
);
}
export function ReportTables({
report,
section,
}: {
report: SeafarerReport;
section: Tab;
}) {
const { t } = useTranslation();
const tt = (key: string, opts?: Record<string, unknown>) =>
t(`seafarerAnalytics.tables.${key}`, opts);
const col = (key: string) => tt(`cols.${key}`);
const label = useLabeller();
const showDate = useDateDisplayer();
const { tables, filters } = report;
const nothing = tt('nothing');
const unnamed = tt('unnamed');
const tierLabel = (tier: 'ABOVE' | 'BELOW' | null) =>
tier && (
<Text size="xs" c="dimmed">
{tier === 'ABOVE' ? tt('management') : tt('operational')}
</Text>
);
/** 0 is today, and a document is valid through its last day. */
const daysBadge = (days: number) => (
<Badge size="sm" variant="light" color={expiryUrgency(days)}>
{days === 0 ? tt('today') : tt('days', { count: days })}
</Badge>
);
/** Red past a month waiting, orange past a fortnight. */
const waitingBadge = (days: number) => (
<Badge size="sm" variant="light" color={days > 30 ? 'red' : days > 14 ? 'orange' : 'gray'}>
{tt('days', { count: days })}
</Badge>
);
const pending = (
<TableCard
key="pending"
title={tt('pending')}
subtitle={tt('pendingSub')}
to={ROUTES.registrationQueue}
linkLabel={tt('openQueue')}
empty={tables.pendingRegistrations.length === 0}
emptyText={nothing}
head={[col('applicant'), col('status'), col('submitted'), col('open')]}
>
{tables.pendingRegistrations.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Holder
name={row.applicantName}
reference={[row.registrationNumber, row.department].filter(Boolean).join(' · ')}
unnamed={unnamed}
/>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={REGISTRATION_STATUS_COLORS[row.status]}>
{label('status')(row.status, row.status)}
</Badge>
</Table.Td>
<Table.Td>{row.submittedAt ? showDate(row.submittedAt) : '—'}</Table.Td>
<Table.Td>{waitingBadge(row.daysOpen)}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
const verifications = (
<TableCard
key="verifications"
title={tt('verifications')}
subtitle={tt('verificationsSub')}
to={ROUTES.seaServiceDesk}
linkLabel={tt('openDesk')}
empty={tables.pendingVerifications.length === 0}
emptyText={nothing}
head={[col('seafarer'), col('record'), col('submitted'), col('waiting')]}
>
{tables.pendingVerifications.map((row) => (
<Table.Tr key={`${row.recordType}-${row.id}`}>
<Table.Td>
<Holder name={row.holderName} reference={row.registrationNumber} unnamed={unnamed} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={row.recordType === 'MEDICAL' ? 'pink' : 'blue'}>
{label('recordType')(row.recordType, row.recordType)}
</Badge>
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{row.summary}
</Text>
</Table.Td>
<Table.Td>{showDate(row.submittedAt)}</Table.Td>
<Table.Td>{waitingBadge(row.daysWaiting)}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
/**
* The one worklist that is a finding rather than a queue: nobody has filed
* anything, which is exactly why a dashboard has to surface it.
*/
const eligible = (
<TableCard
key="eligible"
title={tt('eligible')}
subtitle={tt('eligibleSub')}
to={ROUTES.cocQueue}
linkLabel={tt('openQueue')}
empty={tables.eligibleUncertified.length === 0}
emptyText={nothing}
head={[col('seafarer'), col('department'), col('seaTime'), col('holdsCop'), col('lastDischarge')]}
>
{tables.eligibleUncertified.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Holder name={row.applicantName} reference={row.seafarerNumber ?? row.registrationNumber} unnamed={unnamed} />
</Table.Td>
<Table.Td>
{row.department ?? '—'}
{tierLabel(row.tier)}
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{formatSeaTime(row.seaDays)}
</Text>
<Text size="xs" c="dimmed">
{formatNumber(row.seaDays)} d
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={row.holdsCop ? 'teal' : 'gray'}>
{row.holdsCop ? tt('holdsCopYes') : tt('holdsCopNo')}
</Badge>
</Table.Td>
<Table.Td>{row.lastDischargeDate ? showDate(row.lastDischargeDate) : '—'}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
const expiringCertificates = (
<TableCard
key="expiringCertificates"
title={tt('expiringCertificates')}
subtitle={tt('withinDays', { count: filters.expiringWithinDays })}
to={ROUTES.licenceRegister}
linkLabel={tt('viewAll')}
empty={tables.expiringCertificates.length === 0}
emptyText={nothing}
head={[col('holder'), col('certificate'), col('expires'), col('daysLeft')]}
>
{tables.expiringCertificates.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Holder name={row.holderName} reference={row.registrationNumber} unnamed={unnamed} />
</Table.Td>
<Table.Td>
<Text size="sm">{row.certificateNumber}</Text>
<Text size="xs" c="dimmed">
{label('certificateType')(row.typeKey, row.typeKey)}
{row.rank ? ` · ${row.rank}` : ''}
</Text>
</Table.Td>
<Table.Td>{showDate(row.expiryDate)}</Table.Td>
<Table.Td>{daysBadge(row.daysToExpiry)}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
const expiringDocuments = (
<TableCard
key="expiringDocuments"
title={tt('expiringDocuments')}
subtitle={tt('expiringDocumentsSub', { count: filters.expiringWithinDays })}
to={ROUTES.seamanBookQueue}
linkLabel={tt('openQueue')}
empty={tables.expiringDocuments.length === 0}
emptyText={nothing}
head={[col('holder'), col('document'), col('expires'), col('daysLeft')]}
>
{tables.expiringDocuments.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Holder name={row.holderName} reference={row.registrationNumber} unnamed={unnamed} />
</Table.Td>
<Table.Td>
<Text size="sm">{row.documentNumber ?? row.requestNumber}</Text>
<Text size="xs" c="dimmed">
{label('documentKind')(row.kind, row.kind)}
</Text>
</Table.Td>
<Table.Td>{showDate(row.expiryDate)}</Table.Td>
<Table.Td>{daysBadge(row.daysToExpiry)}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
const expiringMedicals = (
<TableCard
key="expiringMedicals"
title={tt('expiringMedicals')}
subtitle={tt('withinDays', { count: filters.expiringWithinDays })}
to={ROUTES.medicalDesk}
linkLabel={tt('openDesk')}
empty={tables.expiringMedicals.length === 0}
emptyText={nothing}
head={[col('seafarer'), col('issuer'), col('fitness'), col('daysLeft')]}
>
{tables.expiringMedicals.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Holder name={row.holderName} reference={row.registrationNumber} unnamed={unnamed} />
</Table.Td>
<Table.Td>
<Text size="sm">{row.issuerName}</Text>
<Text size="xs" c="dimmed">
{row.certificateNumber ?? '—'} · {tt('expiresOn', { date: showDate(row.expiryDate) })}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={FITNESS_COLORS[row.fitnessStatus]}>
{label('fitness')(row.fitnessStatus, row.fitnessStatus)}
</Badge>
</Table.Td>
<Table.Td>{daysBadge(row.daysToExpiry)}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
const recent = (
<TableCard
key="recent"
title={tt('recent')}
to={ROUTES.registry}
linkLabel={tt('openRegistry')}
empty={tables.recentRegistrations.length === 0}
emptyText={nothing}
head={[col('seafarer'), col('department'), col('nationality'), col('approved')]}
>
{tables.recentRegistrations.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Holder name={row.applicantName} reference={row.seafarerNumber ?? row.registrationNumber} unnamed={unnamed} />
</Table.Td>
<Table.Td>
{row.department ?? '—'}
{tierLabel(row.tier)}
</Table.Td>
<Table.Td>{row.nationality ?? '—'}</Table.Td>
<Table.Td>{row.decidedAt ? showDate(row.decidedAt) : '—'}</Table.Td>
</Table.Tr>
))}
</TableCard>
);
const sections: Record<Tab, ReactNode[]> = {
overview: [pending, eligible, verifications, expiringCertificates],
registration: [pending, recent],
documents: [expiringDocuments, expiringCertificates],
medical: [verifications, expiringMedicals, eligible],
};
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="md">
{sections[section]}
</SimpleGrid>
);
}

View File

@@ -0,0 +1,211 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Alert, Container, Tabs } from '@mantine/core';
import {
IconAlertTriangle,
IconCertificate,
IconHeartbeat,
IconId,
IconLayoutDashboard,
IconUsers,
} from '@tabler/icons-react';
import {
ApiErrorAlert,
EmptyState,
notify,
PageHeader,
PageLoader,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LICENSE_PERMISSIONS as P, usePermissions } from '@ema-platform/auth';
import {
downloadAuthedFile,
extractErrorMessage,
useGetAssignableOfficersQuery,
useGetSeafarerReportQuery,
} from '@ema-platform/api';
import type { SeafarerReportQuery } from '@ema-platform/api';
import { KpiTiles } from './KpiTiles';
import { ReportCharts } from './ReportCharts';
import { ReportFilters } from './ReportFilters';
import { ReportTables } from './ReportTables';
import {
isTab,
queryToSearchParams,
searchParamsToQuery,
type Tab,
TABS,
} from './report-format';
const TAB_ICONS = {
overview: IconLayoutDashboard,
registration: IconId,
documents: IconCertificate,
medical: IconHeartbeat,
} as const;
/**
* The seafarer services dashboard.
*
* One `GET /seafarer-registry/report` call fills every tab — KPIs, five time
* series, twenty-two breakdowns and seven worklists — so the filter bar drives
* a single refetch and switching tabs costs nothing. The alternative, fanning
* out to the registration, document, certificate, medical and sea-service list
* endpoints and adding up the pages in the browser, would give totals that are
* only ever as complete as the first page of each.
*
* Filter state and the active tab both live in the URL. A filtered dashboard
* is the thing an officer wants to send someone, and rebuilding seven selects
* from a description is not how that conversation should go.
*/
export function SeafarerAnalyticsPage() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [exporting, setExporting] = useState(false);
const showDate = useDateDisplayer();
const { can } = usePermissions();
const query: SeafarerReportQuery = useMemo(
() => searchParamsToQuery(searchParams),
[searchParams],
);
const rawTab = searchParams.get('tab');
const tab: Tab = isTab(rawTab) ? rawTab : 'overview';
const setQuery = useCallback(
(next: SeafarerReportQuery) => {
const params = queryToSearchParams(next);
if (tab !== 'overview') params.set('tab', tab);
// `replace` so a session of narrowing filters does not bury the page the
// officer arrived from under twenty history entries.
setSearchParams(params, { replace: true });
},
[setSearchParams, tab],
);
const setTab = useCallback(
(next: string | null) => {
const params = queryToSearchParams(query);
if (next && next !== 'overview') params.set('tab', next);
setSearchParams(params, { replace: true });
},
[setSearchParams, query],
);
const { data: report, isLoading, isFetching, error } =
useGetSeafarerReportQuery(query);
// Reviewer ids are IAM uuids; the officers list is what turns them into
// names. It sits behind the application-review permission, so it is only
// asked for when the viewer holds it — the chart falls back to shortened ids.
const { data: officers } = useGetAssignableOfficersQuery(undefined, {
skip: !can([P.VIEW_APPLICATIONS]),
});
const officerNames = useMemo(
() => new Map((officers ?? []).flatMap((o) => (o.name ? [[o.id, o.name] as const] : []))),
[officers],
);
const exportCsv = useCallback(async () => {
setExporting(true);
try {
const params = queryToSearchParams(query).toString();
const { rowCount, truncated } = await downloadAuthedFile(
`/seafarer-registry/report/export${params ? `?${params}` : ''}`,
'seafarer-register.csv',
);
if (truncated) {
notify.error(t('seafarerAnalytics.exportCutOff', { count: rowCount ?? 0 }));
} else {
notify.success(t('seafarerAnalytics.exportDone', { count: rowCount ?? 0 }));
}
} catch (err) {
notify.error(extractErrorMessage(err, t('seafarerAnalytics.exportError')));
} finally {
setExporting(false);
}
}, [query, t]);
// Only the very first load blanks the page; a filter change keeps the last
// report on screen so the dashboard does not flash between every tweak.
if (isLoading) return <PageLoader />;
return (
<Container size="xl" py="md">
<PageHeader
title={t('seafarerAnalytics.title')}
subtitle={
report
? t('seafarerAnalytics.subtitleWindow', {
from: showDate(report.filters.from),
to: showDate(report.filters.to),
})
: t('seafarerAnalytics.subtitleDefault')
}
/>
<ReportFilters
query={query}
onChange={setQuery}
report={report}
onExport={exportCsv}
exporting={exporting}
/>
{error && (
<ApiErrorAlert error={error} title={t('seafarerAnalytics.loadError')} />
)}
{report && (
<>
{report.truncated && (
<Alert
color="yellow"
icon={<IconAlertTriangle size={16} />}
mb="md"
title={t('seafarerAnalytics.partialTitle')}
>
{t('seafarerAnalytics.partialBody')}
</Alert>
)}
{report.kpis.registry.total === 0 ? (
<EmptyState
icon={IconUsers}
title={t('seafarerAnalytics.emptyTitle')}
description={
Object.keys(query).length > 0
? t('seafarerAnalytics.emptyFiltered')
: t('seafarerAnalytics.emptyNone')
}
/>
) : (
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List mb="md">
{TABS.map((key) => {
const TabIcon = TAB_ICONS[key];
return (
<Tabs.Tab key={key} value={key} leftSection={<TabIcon size={15} />}>
{t(`seafarerAnalytics.tabs.${key}`)}
</Tabs.Tab>
);
})}
</Tabs.List>
<div style={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 120ms' }}>
<KpiTiles report={report} section={tab} />
<div style={{ marginTop: 'var(--mantine-spacing-md)' }}>
<ReportCharts report={report} section={tab} officerNames={officerNames} />
</div>
<ReportTables report={report} section={tab} />
</div>
</Tabs>
)}
</>
)}
</Container>
);
}
export default SeafarerAnalyticsPage;

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest';
import type { BreakdownItem } from '@ema-platform/api';
import {
DASH,
expiryBands,
formatSeaTime,
isTab,
queryToSearchParams,
relabel,
searchParamsToQuery,
} from './report-format';
const item = (key: string, count = 1): BreakdownItem => ({
key,
label: key,
count,
percentage: 0,
});
describe('formatSeaTime', () => {
it('says "None" rather than "0 d" for a seafarer with no service', () => {
// Zero sea time is a finding, not a missing measurement — the two have to
// read differently.
expect(formatSeaTime(0)).toBe('None');
expect(formatSeaTime(null)).toBe(DASH);
expect(formatSeaTime(undefined)).toBe(DASH);
});
it('switches to the unit the STCW thresholds are argued in', () => {
expect(formatSeaTime(45)).toBe('45 d');
// Past two months a reader wants months, past two years, years.
expect(formatSeaTime(365)).toBe('12.0 mo');
expect(formatSeaTime(1096)).toBe('3.0 yr');
});
});
describe('expiryBands', () => {
it('differences the cumulative counts into disjoint bands', () => {
// The API answers cumulatively: 11 due within 30 days are also inside the
// 60- and 90-day figures. Drawn side by side they must not double-count.
expect(
expiryBands({ expiringIn30: 5, expiringIn60: 9, expiringIn90: 12 }),
).toEqual([
{ label: 'Within 30 days', count: 5 },
{ label: '3160 days', count: 4 },
{ label: '6190 days', count: 3 },
]);
});
it('never draws a negative bar if the server answers non-monotonically', () => {
expect(
expiryBands({ expiringIn30: 9, expiringIn60: 4, expiringIn90: 2 }),
).toEqual([
{ label: 'Within 30 days', count: 9 },
{ label: '3160 days', count: 0 },
{ label: '6190 days', count: 0 },
]);
});
});
describe('relabel', () => {
it('asks the resolver per key and keeps its fallback', () => {
const labels: Record<string, string> = {
CERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
};
const [coc, other] = relabel(
[item('CERTIFICATE_OF_COMPETENCY'), item('OTHER')],
(key, fallback) => labels[key] ?? fallback,
);
expect(coc.label).toBe('Certificate of Competency');
// "Other" is a bookkeeping slice with no domain label to swap in.
expect(other.label).toBe('OTHER');
});
});
describe('isTab', () => {
it('accepts only the four sections and falls through otherwise', () => {
expect(isTab('registration')).toBe(true);
expect(isTab('medical')).toBe(true);
// A hand-edited or stale URL must not select a tab that does not exist.
expect(isTab('incidents')).toBe(false);
expect(isTab(null)).toBe(false);
});
});
describe('url round trip', () => {
it('carries every filter through a shared link', () => {
const query = {
from: '2026-01-01',
to: '2026-06-30',
granularity: 'WEEK' as const,
status: ['APPROVED' as const, 'UNDER_REVIEW' as const],
department: ['DECK'],
tier: ['ABOVE' as const],
gender: ['FEMALE' as const],
nationality: ['Ethiopia'],
search: 'abebe',
topN: 20,
tableLimit: 25,
expiringWithinDays: 60,
};
expect(searchParamsToQuery(queryToSearchParams(query))).toEqual(query);
});
it('keeps the tab out of the API query', () => {
// `tab` is page state; forwarded, it would fail the API's validation.
const params = new URLSearchParams('tab=medical&search=abebe');
expect(searchParamsToQuery(params)).toEqual({ search: 'abebe' });
});
it('drops empty arrays and blanks rather than serialising them', () => {
const params = queryToSearchParams({
status: [],
search: '',
department: undefined,
});
expect(params.toString()).toBe('');
});
it('ignores a hand-edited value the API would reject', () => {
const params = new URLSearchParams(
'topN=not-a-number&granularity=CENTURY&search=abebe',
);
// The unparseable number and the bogus granularity are dropped rather
// than forwarded to fail the API's validation pipe.
expect(searchParamsToQuery(params)).toEqual({ search: 'abebe' });
});
});

View File

@@ -0,0 +1,162 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type {
BreakdownItem,
MedicalFitness,
ReportGranularity,
SeafarerRegistrationStatus,
SeafarerReportQuery,
} from '@ema-platform/api';
import {
formatBucket,
searchParamsToQuery as searchParamsToQueryWith,
} from '@ema-platform/ui';
/**
* Presentation rules for the seafarer services report.
*
* The generic half — number and delta formatting, the palette, bucket labels,
* URL round-tripping — lives in `@ema-platform/ui` and is re-exported here so
* this feature's components keep one import. What stays is what only this
* report knows: its colours, its units, and which of its filter keys are
* arrays and which are numbers. Wording lives in the locale files under
* `seafarerAnalytics`, not here — this page is bilingual like the rest of the
* backoffice.
*/
export {
DASH,
defaultRange,
deltaColor,
expiryBands,
expiryUrgency,
formatDays,
formatDelta,
formatMoney,
formatNumber,
formatPercent,
ISO_DAY_LENGTH,
officerLabel,
optionsFrom,
queryToSearchParams,
sliceColor,
toIsoDay,
} from '@ema-platform/ui';
/** Where each tile takes the officer when clicked. */
export const ROUTES = {
registry: '/seafarer-registry',
registrationQueue: '/seafarer-registrations',
biometrics: '/biometric-enrollment',
seamanBookQueue: '/seaman-book-queue',
btcQueue: '/btc-queue',
licenceRegister: '/licence-register',
cocQueue: '/licence-review/type/CERTIFICATE_OF_COMPETENCY',
medicalDesk: '/medical-verification',
seaServiceDesk: '/sea-service-verification',
payments: '/payment-config',
} as const;
/** Mantine colour per registration status, for the queue badges. */
export const REGISTRATION_STATUS_COLORS: Record<
SeafarerRegistrationStatus,
string
> = {
DRAFT: 'gray',
AWAITING_BIOMETRICS: 'grape',
UNDER_REVIEW: 'blue',
SUBMITTED: 'cyan',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
};
export const FITNESS_COLORS: Record<MedicalFitness, string> = {
FIT: 'teal',
FIT_WITH_RESTRICTIONS: 'yellow',
UNFIT: 'red',
};
/**
* A breakdown relabelled through the locale, leaving keys the locale does not
* know (free text such as a department code, and the "Other" bucket) as they
* came from the API.
*/
export function relabel(
items: BreakdownItem[],
label: (key: string, fallback: string) => string,
): BreakdownItem[] {
return items.map((item) => ({ ...item, label: label(item.key, item.label) }));
}
/**
* A relabeller bound to one locale namespace, e.g. `seafarerAnalytics.status`.
* i18next's `defaultValue` is what makes an unknown key fall through.
*/
export function useLabeller() {
const { t } = useTranslation();
return useCallback(
(namespace: string) => (key: string, fallback: string) =>
t(`seafarerAnalytics.${namespace}.${key}`, { defaultValue: fallback }),
[t],
);
}
/**
* The axis tick formatter, subscribed to the active language so the chart's
* calendar flips with everything else on the page.
*/
export function useBucketTick(granularity: ReportGranularity) {
const { i18n } = useTranslation();
return useCallback(
(bucket: string) => formatBucket(bucket, granularity, i18n.language),
[granularity, i18n.language],
);
}
/**
* Sea time as the unit the STCW thresholds are argued in.
*
* Certificate eligibility is stated in months and years of service, so the
* dashboard says "14.2 mo" where the API says 432 days — the reader is
* checking a figure against a rule, not counting days.
*/
export function formatSeaTime(days: number | null | undefined): string {
if (days === null || days === undefined || Number.isNaN(days)) return '—';
if (days === 0) return 'None';
if (days < 61) return `${Math.round(days)} d`;
if (days < 730) return `${(days / 30.44).toFixed(1)} mo`;
return `${(days / 365.25).toFixed(1)} yr`;
}
/** The dashboard's sections; the active one is kept in the URL. */
export const TABS = ['overview', 'registration', 'documents', 'medical'] as const;
export type Tab = (typeof TABS)[number];
export function isTab(value: string | null): value is Tab {
return TABS.includes(value as Tab);
}
const ARRAY_KEYS = [
'status',
'department',
'tier',
'gender',
'nationality',
] as const;
const NUMBER_KEYS = ['expiringWithinDays', 'topN', 'tableLimit'] as const;
const STRING_KEYS = ['from', 'to', 'search'] as const;
/**
* Restores this report's filter state from a shared link. `tab` is read
* separately — it is page state, not a filter the API should see.
*/
export const searchParamsToQuery = (
params: URLSearchParams,
): SeafarerReportQuery =>
searchParamsToQueryWith<SeafarerReportQuery>(params, {
arrays: ARRAY_KEYS,
numbers: NUMBER_KEYS,
strings: STRING_KEYS,
});

View File

@@ -1,172 +1,46 @@
import type {
BreakdownItem,
CertificateKpis,
ReportGranularity,
VesselReportQuery,
} from '@ema-platform/api';
/** Nothing measurable is not zero — an em dash says so without lying. */
export const DASH = '—';
import type { CertificateKpis, VesselReportQuery } from '@ema-platform/api';
import {
expiryBands as expiryBandsFrom,
searchParamsToQuery as searchParamsToQueryWith,
} from '@ema-platform/ui';
/**
* A figure the API may legitimately have no answer for.
* Presentation rules for the vessel registration report.
*
* `avgGrossTonnage` is null on an empty register and `approvalRatePct` is null
* until something has been decided; rendering either as 0 would report a fleet
* that weighs nothing and a service that approves nobody.
* The generic half — number and delta formatting, the palette, bucket labels,
* URL round-tripping — moved to `@ema-platform/ui` once the seafarer services
* report needed the same rules; it is re-exported here so this feature's
* components keep one import for the whole toolkit. What stays is what only
* this report knows: which of its filter keys are arrays and which are
* numbers.
*/
export function formatNumber(
value: number | null | undefined,
options: { decimals?: number; suffix?: string } = {},
): string {
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
const text = value.toLocaleString(undefined, {
minimumFractionDigits: options.decimals ?? 0,
maximumFractionDigits: options.decimals ?? 0,
});
return options.suffix ? `${text}${options.suffix}` : text;
}
export function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined
? DASH
: `${formatNumber(value, { decimals: 1 })}%`;
}
export function formatMoney(value: number, currency: string): string {
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
}
/** A signed delta for the change-vs-previous chip. */
export function formatDelta(value: number | null): string {
if (value === null) return DASH;
const sign = value > 0 ? '+' : '';
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
}
export function deltaColor(value: number | null): string {
if (value === null || value === 0) return 'gray';
return value > 0 ? 'teal' : 'red';
}
export {
DASH,
defaultRange,
deltaColor,
expiryUrgency,
formatBucket,
formatDays,
formatDelta,
formatMoney,
formatNumber,
formatPercent,
ISO_DAY_LENGTH,
officerLabel,
optionsFrom,
queryToSearchParams,
sliceColor,
toIsoDay,
} from '@ema-platform/ui';
/**
* The API's expiry counts are cumulative — a certificate due in eleven days is
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
* them. Stacked side by side in a chart that reads as three separate groups,
* so they are differenced into disjoint bands first.
*/
export function expiryBands(
certificates: CertificateKpis,
): Array<{ label: string; count: number }> {
const { expiringIn30, expiringIn60, expiringIn90 } = certificates;
return [
{ label: 'Within 30 days', count: expiringIn30 },
// Math.max guards against a server that ever answers non-monotonically —
// a negative bar is worse than a zero one.
{ label: '3160 days', count: Math.max(0, expiringIn60 - expiringIn30) },
{ label: '6190 days', count: Math.max(0, expiringIn90 - expiringIn60) },
];
}
/** Red inside a week, orange inside a month, otherwise unremarkable. */
export function expiryUrgency(daysToExpiry: number): string {
if (daysToExpiry <= 7) return 'red';
if (daysToExpiry <= 30) return 'orange';
return 'gray';
}
/**
* Officer ids are IAM uuids, which make useless axis labels. Until the
* dashboard has a name lookup, shorten them and keep "UNASSIGNED" readable.
*/
export function officerLabel(key: string): string {
if (key === 'UNASSIGNED') return 'Unassigned';
return key.length > 8 ? `${key.slice(0, 8)}` : key;
}
/**
* Chart colours, assigned by position so a slice keeps its colour between
* renders. Mantine's palette rather than invented hex codes, so the charts
* follow the theme the rest of the app is built on.
*/
const PALETTE = [
'var(--mantine-color-blue-6)',
'var(--mantine-color-teal-6)',
'var(--mantine-color-orange-6)',
'var(--mantine-color-grape-6)',
'var(--mantine-color-cyan-6)',
'var(--mantine-color-lime-7)',
'var(--mantine-color-pink-6)',
'var(--mantine-color-indigo-6)',
];
const MUTED = 'var(--mantine-color-gray-5)';
/**
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
* always take the muted colour instead of competing with the real categories
* for one of the bright ones.
*/
export function sliceColor(item: BreakdownItem, index: number): string {
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
return PALETTE[index % PALETTE.length];
}
/** Bucket keys are ISO dates; the axis wants something a human reads. */
export function formatBucket(
bucket: string,
granularity: ReportGranularity,
): string {
const date = new Date(bucket);
if (Number.isNaN(date.getTime())) return bucket;
if (granularity === 'MONTH') {
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
return date.toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
/** The default window the API applies when none is given: the last 12 months. */
export function defaultRange(now: Date): [Date, Date] {
const from = new Date(
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
);
return [from, now];
}
export const ISO_DAY_LENGTH = 10;
export const toIsoDay = (date: Date): string =>
date.toISOString().slice(0, ISO_DAY_LENGTH);
/**
* The filter state as URL search params, so a filtered dashboard is a
* shareable link rather than something the next person has to rebuild.
* The register's expiry bands.
*
* Empty arrays and blank strings are dropped rather than serialised, which
* keeps an untouched dashboard's URL clean and lets the API apply its own
* defaults instead of being handed an empty filter to honour.
* A thin wrapper over the shared helper so the call site stays typed against
* `CertificateKpis` rather than the structural shape.
*/
export function queryToSearchParams(query: VesselReportQuery): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
params.set(key, value.join(','));
} else {
params.set(key, String(value));
}
}
return params;
}
export const expiryBands = (certificates: CertificateKpis) =>
expiryBandsFrom(certificates);
const ARRAY_KEYS = [
'category',
@@ -178,44 +52,12 @@ const ARRAY_KEYS = [
const NUMBER_KEYS = ['expiringWithinDays', 'topN', 'tableLimit'] as const;
/** The inverse, for restoring state from a shared link. */
export function searchParamsToQuery(
params: URLSearchParams,
): VesselReportQuery {
const query: Record<string, unknown> = {};
for (const key of ARRAY_KEYS) {
const raw = params.get(key);
if (raw) query[key] = raw.split(',').filter(Boolean);
}
for (const key of NUMBER_KEYS) {
const raw = params.get(key);
// An unparseable number in a hand-edited URL is ignored rather than sent
// on to fail the API's validation pipe.
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
query[key] = Number(raw);
}
}
for (const key of ['from', 'to', 'search'] as const) {
const raw = params.get(key);
if (raw) query[key] = raw;
}
const granularity = params.get('granularity');
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
query.granularity = granularity;
}
return query as VesselReportQuery;
}
const STRING_KEYS = ['from', 'to', 'search'] as const;
/**
* The multi-select options a filter offers, taken from the breakdown the last
* response carried — there is no lookup endpoint for flag states or ports, and
* the register is the only place that knows which ones are in use.
*
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
* filter the register down to.
*/
export function optionsFrom(items: BreakdownItem[] | undefined): string[] {
return (items ?? [])
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
.map((item) => item.key);
}
/** Restores this report's filter state from a shared link. */
export const searchParamsToQuery = (params: URLSearchParams): VesselReportQuery =>
searchParamsToQueryWith<VesselReportQuery>(params, {
arrays: ARRAY_KEYS,
numbers: NUMBER_KEYS,
strings: STRING_KEYS,
});

View File

@@ -72,7 +72,7 @@ export const am: Translations = {
vesselFormBuilder: "የመርከብ ቅጽ መገንቢያ",
vesselRegistrationReport: "የመርከብ ምዝገባ ሪፖርት",
ownershipTransferQueue: "የባለቤትነት ዝውውር ወረፋ",
logisticsHeadDashboard: "የሎጂስቲክስ ኃላፊ ዳሽቦርድ",
logisticsHeadDashboard: "የሎጂስቲክስ ክፍል",
freightForwarderLicense: "የጭነት አስተላላፊ ፈቃድ",
shippingAgentLicense: "የመርከብ ወኪል ፈቃድ",
combinedLicense: "የተቀናጀ ፈቃድ",
@@ -92,6 +92,7 @@ export const am: Translations = {
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
seafarerAnalytics: "የመርከበኞች ትንተና",
seafarerRegistry: "የመርከበኞች መዝገብ",
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
@@ -184,6 +185,253 @@ export const am: Translations = {
},
},
/** የሎጂስቲክስ ኃላፊ ዳሽቦርድ — የኦፕሬተር ፈቃድ ቤተሰብ ብቻ። */
logisticsHead: {
title: "የሎጂስቲክስ ክፍል",
subtitle:
"የኦፕሬተር ፈቃድ አሰጣጥ — በክፍሉ ውስጥ ያለው እያንዳንዱ ማመልከቻ፣ የያዘው ሰው እና የዘገየው።",
loading: "የሎጂስቲክስ ክፍል በመጫን ላይ…",
errorTitle: "የክፍሉ የሥራ ጫና መጫን አልተቻለም",
errorBody: "የፈቃድ ማመልከቻ ወረፋው ምላሽ አልሰጠም። ምንም አልተለወጠም — እንደገና ይሞክሩ።",
typeFilter: "የፈቃድ ዓይነት",
allTypes: "ሁሉም የኦፕሬተር ፈቃዶች",
openQueue: "ወረፋውን ክፈት",
exportHint: "የክፍሉን ሪፖርት አውርድ",
exportDone: "የክፍሉ ሪፖርት እንደ {{filename}} ተቀምጧል።",
unassigned: "ያልተመደበ",
truncated:
"ከ{{total}} ውስጥ በዕድሜ የገፉት {{shown}} ክፍት ማመልከቻዎች እየታዩ ነው። ሙሉውን ክፍል የሚሸፍን አኃዝ ለማግኘት በፈቃድ ዓይነት ያጥቡ።",
truncatedHistory:
"የመቀበያ ገበታው በዚህ ጊዜ ውስጥ ከቀረቡት {{total}} ማመልከቻዎች የቅርብ ጊዜዎቹን {{shown}} ብቻ ስለሚሸፍን የመጀመሪያዎቹ ወራት ዝቅ ብለው ይታያሉ። አጭር ጊዜ ወይም አንድ የፈቃድ ዓይነት ይምረጡ።",
overdueTitle: "የማስተናገጃ ጊዜ ግቦች አልተሟሉም",
overdueBody_one:
"{{count}} ማመልከቻ ለፈቃድ ዓይነቱ ከተቀመጠው የማስተናገጃ ጊዜ ግብ አልፏል።",
overdueBody_other:
"{{count}} ማመልከቻዎች ለፈቃድ ዓይነታቸው ከተቀመጠው የማስተናገጃ ጊዜ ግብ አልፈዋል።",
overdueAction: "አሳየኝ",
dispatchTitle: "ለሠራተኛ ያልተሰጠ ሥራ",
dispatchBody_one:
"{{count}} ማመልከቻ ለ{{days}} ቀናት ወይም ከዚያ በላይ ለማንም ሳይመደብ ቆይቷል።",
dispatchBody_other:
"{{count}} ማመልከቻዎች ለ{{days}} ቀናት ወይም ከዚያ በላይ ለማንም ሳይመደቡ ቆይተዋል።",
dispatchAction: "አሁን መድብ",
clear: {
title: "ክፍሉ ባዶ ነው",
body:
"ክፍት የኦፕሬተር ፈቃድ ማመልከቻ የለም — የሚመደብ፣ የዘገየ ወይም ክፍያ የሚጠብቅ የለም። እድሳቶችና የመቀበያ ታሪክ ከታች አሉ።",
bodyFiltered:
"ክፍት የ{{type}} ማመልከቻ የለም — የሚመደብ፣ የዘገየ ወይም ክፍያ የሚጠብቅ የለም። ሙሉውን ክፍል ለማየት የፈቃድ ዓይነት ማጣሪያውን ያጽዱ።",
action: "ቢሆንም ሙሉውን ወረፋ ክፈት",
},
periods: {
"3m": "ያለፉት 3 ወራት",
"6m": "ያለፉት 6 ወራት",
"12m": "ያለፉት 12 ወራት",
},
periodsShort: {
"3m": "3ወ",
"6m": "6ወ",
"12m": "12ወ",
},
kpis: {
open: "ክፍት",
openHint: "መካከለኛ ዕድሜ {{days}}ቀ · በ{{window}}ቀ ውስጥ {{arrived}} ገብተዋል",
overdueHintRecent_one: "በመጨረሻዎቹ {{window}}ቀ ውስጥ {{count}} ዘግይቷል",
overdueHintRecent_other: "በመጨረሻዎቹ {{window}}ቀ ውስጥ {{count}} ዘግይተዋል",
dispatch: "ለመመደብ በመጠባበቅ ላይ",
dispatchHint: "ቀርቧል፣ ማንም አልተመደበም",
overdue: "የዘገየ",
overdueHint: "ሌሎች {{count}} አደጋ ላይ",
applicant: "በአመልካች እጅ",
applicantHint: "ማስተካከያ ተጠይቋል",
fee: "ክፍያ በመጠባበቅ ላይ",
feeHint: "ጸድቋል፣ አልተከፈለም",
issue: "ለመስጠት ዝግጁ",
issueHint: "ተከፍሎ ተረጋግጧል",
},
columns: {
age: "ዕድሜ",
days: "{{count}}ቀ",
sla: "SLA",
officer: "ሠራተኛ",
neverSubmitted: "አልቀረበም",
},
stages: {
intake: {
label: "መቀበያ",
hint: "ቀርቧል፤ ለሠራተኛ ለመሰጠት እየተጠባበቀ ነው",
},
review: {
label: "ግምገማ",
hint: "ሠራተኛ ማመልከቻውንና ሰነዶቹን እየመረመረ ነው",
},
evaluation: {
label: "ምዘና",
hint: "ካፒታል፣ የሠራተኛ ስብጥርና ብቁነት እየተመዘነ ነው",
},
inspection: {
label: "ምርመራ",
hint: "የቦታ ጉብኝት ተይዟል፣ ተካሂዷል ወይም ተዘግቧል",
},
applicant: {
label: "በአመልካች እጅ",
hint: "ማስተካከያ ተጠይቋል — አመልካቹ እስኪመልስ ምንም አይንቀሳቀስም",
},
payment: {
label: "ክፍያ",
hint: "ጸድቋል፤ የፈቃድ ክፍያው እስኪጠናቀቅ በመጠባበቅ ላይ",
},
issuance: {
label: "ሰርተፍኬት አሰጣጥ",
hint: "ተከፍሎ ተረጋግጧል — የቀረው ሰርተፍኬቱ ብቻ ነው",
},
hold: {
label: "ለጊዜው ተይዟል",
hint: "በሠራተኛ ተይዟል፤ ከመደበኛው ፍሰት ውጭ",
},
},
board: {
title: "በደረጃ የተከፋፈለ ፍሰት",
subtitle:
"ክፍት ማመልከቻዎች በክፍሉ ውስጥ በሚንቀሳቀሱበት ቅደም ተከተል። በወረፋው ለመክፈት አንድ ደረጃ ይምረጡ።",
openCount: "{{count}} ክፍት",
lateChip: "{{count}} የዘገየ",
clear: "ባዶ",
medianAge: "መካከለኛ {{days}}ቀ · አንጋፋ {{oldest}}ቀ",
heldTitle_one: "{{count}} ለጊዜው ተይዟል",
heldTitle_other: "{{count}} ለጊዜው ተይዘዋል",
heldHint: "ከላይ ካለው ፍሰት ውጭ — በአማካይ {{days}}ቀ ተይዘዋል። እንደ ደረጃ አይቆጠርም።",
bottleneckTitle: "በጣም የዘገየ ደረጃ",
bottleneckBody:
"{{count}} ማመልከቻዎች በ{{stage}} ደረጃ ላይ ናቸው፤ ግማሾቹ ለ{{days}} ቀናት ወይም ከዚያ በላይ ቆይተዋል። ክፍሉ በጣም ብዙ ጊዜ የሚያጣው እዚህ ላይ ነው።",
},
ageBands: {
"0-3": "03 ቀናት",
"4-7": "47 ቀናት",
"8-14": "814 ቀናት",
"15-30": "1530 ቀናት",
"30+": "ከ30 ቀናት በላይ",
},
charts: {
intake: {
title: "በወር የገቡ ማመልከቻዎች",
subtitle: "በየወሩ የገቡ ማመልከቻዎችና ዛሬ የደረሱበት ሁኔታ",
empty: "በዚህ ጊዜ ውስጥ ምንም የሎጂስቲክስ ማመልከቻ አልቀረበም።",
issued: "ተሰጥቷል",
rejected: "ውድቅ ተደርጓል",
open: "እስካሁን ክፍት",
submitted: "የገቡ",
partialMonth:
"* የአሁኑ ወር ገና አላለቀም፤ ስለዚህ አምዱ የከፊል ወር መረጃ ብቻ ነው የሚይዘው።",
},
ageing: {
title: "የዕድሜ ስብጥር",
subtitle: "ክፍት ማመልከቻዎች የቆዩበት ጊዜ በክልል",
median: "መካከለኛ {{days}}ቀ",
empty: "ምንም ክፍት የለም፤ ስለዚህ የሚያረጅ የለም።",
tooltip: "ክፍት ማመልከቻዎች",
},
sla: {
title: "የማስተናገጃ ጊዜ ጤንነት",
subtitle_one: "ግብ ካላቸው {{count}} ክፍት ማመልከቻ ተሰልቷል",
subtitle_other: "ግብ ካላቸው {{count}} ክፍት ማመልከቻዎች ተሰልቷል",
onTime: "{{rate}}% በጊዜው",
ring: "በግቡ ውስጥ",
overdue: "የዘገየ",
overdueHint: "ከግባቸው አልፈዋል",
atRisk: "አደጋ ላይ",
atRiskHint: "ከጊዜው 70% አልፏል",
untracked_one:
"{{count}} ክፍት ማመልከቻ የማስተናገጃ ጊዜ ግብ የሌለው ዓይነት ስለሆነ ከዚህ አኃዝ ውጭ ነው።",
untracked_other:
"{{count}} ክፍት ማመልከቻዎች የማስተናገጃ ጊዜ ግብ የሌላቸው ዓይነት ስለሆኑ ከዚህ አኃዝ ውጭ ናቸው።",
},
},
officers: {
title: "የሠራተኞች የሥራ ጫና",
subtitle: "በሠራተኛ ያሉ ክፍት ማመልከቻዎች፤ የከፋው የSLA ሁኔታ በቅድሚያ",
count_one: "{{count}} ሠራተኛ",
count_other: "{{count}} ሠራተኞች",
emptyTitle: "ምንም በሂደት ላይ የለም",
emptyBody: "አሁን ለማንም የተመደበ ክፍት የሎጂስቲክስ ማመልከቻ የለም።",
officer: "ሠራተኛ",
load: "ጫና",
active: "ክፍት",
oldest: "አንጋፋ",
loadTooltip: "{{onTrack}} በመስመር · {{atRisk}} አደጋ ላይ · {{overdue}} የዘገየ",
medianTick: "· የቡድን መካከለኛ {{count}}",
},
types: {
title: "በፈቃድ ዓይነት",
subtitle: "በእያንዳንዱ የኦፕሬተር ፈቃድ ያለው ክፍት ሥራ፣ ከዓይነቱ ግብ አንጻር",
emptyTitle: "ክፍት ማመልከቻ የለም",
emptyBody: "በአሁኑ ማጣሪያ ሁሉም የኦፕሬተር ፈቃድ ዓይነቶች ባዶ ናቸው።",
type: "የፈቃድ ዓይነት",
open: "ክፍት",
unassigned: "ያልተመደበ",
late: "የዘገየ",
median: "መካከለኛ ዕድሜ",
target: "ግብ",
targetDays: "{{count}}ቀ",
noTarget: "አይከታተልም",
inspected: "ምርመራ ያስፈልጋል",
atRisk: "{{count}} አደጋ ላይ",
},
renewals: {
title: "የእድሳት ትንበያ",
subtitle: "ጊዜያቸው ሊያልቅ የተቃረቡ ንቁ የኦፕሬተር ፈቃዶች",
active: "{{count}} ንቁ",
within30: "በ30 ቀናት ውስጥ የሚያልቁ",
within60: "በ3160 ቀናት የሚያልቁ",
within90: "በ6190 ቀናት የሚያልቁ",
expired: "ጊዜያቸው ያለፈ",
suspended: "የታገዱ",
next: "በቅርቡ የሚያልቁ",
none: "በሚቀጥሉት 90 ቀናት ጊዜው የሚያልቅ ንቁ የኦፕሬተር ፈቃድ የለም።",
inDays: "{{count}}ቀ",
partial:
"መዝገቡ ከረድፎቹ በከፊል ብቻ ስለመለሰ እነዚህ አኃዞች የተጫነውን እንጂ የተሰጠውን እያንዳንዱን ፈቃድ አይሸፍኑም።",
openRegister: "የፈቃድ መዝገቡን ክፈት",
},
worklists: {
dispatch: {
label: "ለመመደብ በመጠባበቅ ላይ",
empty: "የቀረበ እያንዳንዱ ማመልከቻ ለሠራተኛ ተሰጥቷል።",
},
sla: {
label: "የSLA ቅድሚያ",
empty: "የዘገየ ወይም ለመዘግየት የተቃረበ የለም።",
},
applicant: {
label: "በአመልካች እጅ",
empty: "አመልካቹን የሚጠብቅ ማመልከቻ የለም።",
},
issue: {
label: "ለመስጠት ዝግጁ",
empty: "ሰርተፍኬት የሚጠብቅ የለም።",
},
},
scope: {
title: "ይህ ገጽ የሚሸፍነው",
body:
"እዚህ ያለው እያንዳንዱ አኃዝ የኦፕሬተር ፈቃድ ቤተሰብ ብቻ ነው — {{types}} የፈቃድ ዓይነቶች — የመርከበኞች ሰርተፍኬቶችን፣ የመርከበኞች መጽሐፍትንና የመርከብ ምዝገባዎችን አያካትትም፤ እነዚያ የሌሎች ዴስኮች ናቸው።",
filter: "የአሁኑ ማጣሪያ፡ {{type}} · የመቀበያ ጊዜ {{period}}።",
},
},
exam: {
title: "ፈተናዎች",
subtitle: "ፈተናዎችን ያስተዳድሩ፣ ጥያቄዎችን ይመድቡ እና ውጤቶችን ይከታተሉ",
@@ -1801,6 +2049,231 @@ export const am: Translations = {
},
},
seafarerAnalytics: {
title: "የመርከበኞች አገልግሎት",
subtitleDefault: "ምዝገባ፣ የምስክር ወረቀት፣ ሰነዶች፣ የሕክምና ብቃት እና የባህር አገልግሎት በአንድ እይታ።",
subtitleWindow: "የመዝገቡ አጠቃላይ ድምር ከ{{from}} {{to}} መስኮት ጋር በአዝማሚያዎቹ ላይ።",
loadError: "ሪፖርቱን መጫን አልተቻለም",
exportError: "መዝገቡን ወደ ውጭ መላክ አልተቻለም",
exportCutOff: "ወደ ውጭ መላክ በ{{count}} ረድፎች ተቋርጧል። ማጣሪያውን አጥብበው እንደገና ይሞክሩ።",
exportDone_one: "{{count}} መርከበኛ ተልኳል።",
exportDone_other: "{{count}} መርከበኞች ተልከዋል።",
partialTitle: "ከፊል አሃዞች",
partialBody: "መዝገቡ ይህ ሪፖርት በአንድ ጊዜ ሊቃኘው ከሚችለው በላይ ነው፤ ስለዚህ ከታች ያለው እያንዳንዱ አሃዝ የከፊሉን ብቻ ይሸፍናል። ትክክለኛ መልስ ለማግኘት ማጣሪያውን ያጥብቡ።",
emptyTitle: "ከዚህ ማጣሪያ ጋር የሚዛመድ መርከበኛ የለም",
emptyFiltered: "በመዝገቡ ላይ ከአሁኑ ማጣሪያ ጋር የሚዛመድ ነገር የለም። ሙሉውን መዝገብ ለማየት ያጽዱት።",
emptyNone: "እስካሁን የተመዘገበ መርከበኛ የለም። ምዝገባ ከቀረበ በኋላ ግቤቶች እዚህ ይታያሉ።",
tabs: {
overview: "አጠቃላይ እይታ",
registration: "ምዝገባ",
documents: "ሰነዶች እና የምስክር ወረቀቶች",
medical: "ሕክምና እና የባህር አገልግሎት",
},
filters: {
period: "ጊዜ",
periodPlaceholder: "ያለፉት 12 ወራት",
day: "ቀን",
week: "ሳምንት",
month: "ወር",
status: "የምዝገባ ሁኔታ",
department: "ክፍል",
tier: "የማዕረግ ደረጃ",
gender: "ጾታ",
nationality: "ዜግነት",
search: "ፈልግ",
searchPlaceholder: "ስም፣ የምዝገባ ቁጥር፣ የመርከበኛ ቁጥር ወይም መታወቂያ",
all: "ሁሉም",
clear: "አጽዳ",
export: "CSV ላክ",
},
tiles: {
onRegister: "በመዝገቡ ላይ ያሉ መርከበኞች",
onRegisterDetail: "{{total}} ምዝገባዎች ቀርበዋል · {{rejected}} ውድቅ · {{draft}} ረቂቅ",
onRegisterHint: "የጸደቁ ምዝገባዎች። ሙሉው መዝገብ — በቀን ማጣሪያ አይነካም።",
inPeriod: "በጊዜው የተመዘገቡ",
inPeriodDetail: "{{previous}} በቀደመው ጊዜ",
inPeriodHint: "በተመረጠው መስኮት ውስጥ የጸደቁ ምዝገባዎች፣ ከቀደመው እኩል ርዝመት ካለው መስኮት ጋር። ማጽደቅ የሚቆጠረው በውሳኔው ቀን እንጂ በማቅረቢያው አይደለም።",
open: "ክፍት ምዝገባዎች",
openDetail: "{{biometrics}} ባዮሜትሪክ በመጠባበቅ · አንጋፋው {{oldest}} ቀን",
openHint: "ቀርበው አሁንም በሂደት ላይ ያሉ። ባዮሜትሪክ የሚጠብቁ ፋይሎች እዚህ ይቆጠራሉ፤ ነገር ግን የምዝገባ ዴስኩ እስኪይዛቸው ድረስ ሊወሰኑ አይችሉም።",
processing: "የማስኬጃ ጊዜ",
processingDetail: "መካከለኛ · አማካይ {{mean}} ቀን · {{decided}} በጊዜው ተወስነዋል",
processingHint: "ከማቅረብ እስከ ውሳኔ። የተወሰኑ ምዝገባዎች ብቻ ይቆጠራሉ።",
approvalRate: "የማጽደቅ መጠን",
approvalRateDetail: "{{approved}} ጸድቀዋል · {{rejected}} ውድቅ · {{filed}} በጊዜው ቀርበዋል",
approvalRateHint: "ከተወሰኑት ምዝገባዎች ውስጥ የጸደቁት ድርሻ። ረቂቆች እና በወረፋ ላይ ያሉ ፋይሎች አይካተቱም።",
documents: "የተሰጡ ሰነዶች",
documentsDetail: "{{seamanBook}} የመርከበኛ መጽሐፍ · {{btc}} BTC · {{open}} ክፍት",
documentsHint: "የመርከበኛ መጽሐፍ እና የመሠረታዊ ሥልጠና ምስክር ወረቀቶች። የተሰጡ ሙሉውን መዝገብ ነው፤ ክፍት ማለት ገና ያልተሰጠ፣ ውድቅ ያልተደረገ ወይም ያልተሰረዘ ጥያቄ ነው።",
certificates: "በሥራ ላይ ያሉ የምስክር ወረቀቶች",
certificatesDetail: "{{coc}} CoC · {{cop}} CoP · {{endorsement}} ማረጋገጫዎች",
certificatesHint: "አሁን በሥራ ላይ ያሉ የብቃት፣ የክህሎት እና የማረጋገጫ የምስክር ወረቀቶች፣ በ{{holders}} ባለቤቶች።",
expiring: "በ30 ቀን ውስጥ የሚያበቁ",
expiringDetail: "{{certificates}} የምስክር ወረቀቶች · {{documents}} ሰነዶች · {{medicals}} የሕክምና",
expiringHint: "መርከበኛ በአንድ ወር ውስጥ ማደስ ያለበት ሁሉ፣ በሦስቱ መደርደሪያዎች ተደምሮ። እያንዳንዱ አሃዝ በራሱ ገበታ ተከማች ነው።",
medical: "የሕክምና ሽፋን",
medicalDetail: "ተሸፍነዋል · {{lapsed}} አብቅቷል · {{unfit}} ብቁ ያልሆኑ · {{toVerify}} ለማረጋገጥ",
medicalHint: "ቢያንስ አንድ በሥራ ላይ ያለ፣ ውድቅ ያልተደረገ የሕክምና ምስክር ወረቀት ያላቸው መርከበኞች። አብቅቷል ማለት ምንም በሥራ ላይ ያለ የለም — የእድሳት ክትትል ዝርዝር።",
seaTime: "የተረጋገጠ የባህር ጊዜ",
seaTimeDetail: "አማካይ · {{over12}} ከ12 ወር በላይ · {{withAny}} ማንኛውም አገልግሎት ያላቸው",
seaTimeHint: "ቢያንስ አንድ የተረጋገጠ ተሳትፎ ባላቸው መርከበኞች ላይ ተመጣጥኖ። ያልተረጋገጡ ጥያቄዎች አይካተቱም — የምስክር ወረቀት ብቁነት በተረጋገጠ አገልግሎት ላይ ብቻ ይመሠረታል።",
eligible: "ብቁ፣ ያልተመሰከረላቸው",
eligibleDetail: "ከ12 ወር በላይ የተረጋገጠ የባህር ጊዜ ያላቸው በሥራ ላይ ያለ CoC የሌላቸው",
eligibleHint: "የSTCW የባህር ጊዜ ገደብ ያለፉ በሥራ ላይ ያለ የብቃት ምስክር ወረቀት የሌላቸው የተመዘገቡ መርከበኞች፣ ያመለከቱም አላመለከቱም።",
workforce: "የሠራተኛ ኃይል መገለጫ",
workforceDetail: "አማካይ ዕድሜ ከ{{total}} ውስጥ በ{{known}} · {{female}} ሴት · {{nationalities}} ዜግነቶች",
workforceHint: "የልደት ቀን እና ጾታ በምዝገባ ላይ አማራጭ ናቸው፤ ስለዚህ እያንዳንዱ አሃዝ ያሳወቁትን መዝገቦች ብቻ ይሸፍናል።",
fees: "የተሰበሰቡ ክፍያዎች",
feesDetail: "{{pending}} ያልተከፈለ · {{failed}} ያልተሳካ",
feesHint: "የመርከበኛ መጽሐፍ እና BTC ክፍያዎች፣ እንዲሁም የምስክር ወረቀት ማመልከቻ ክፍያዎች።",
feesMixedHint: "የመርከበኛ አገልግሎት ክፍያዎች ከአንድ በላይ ምንዛሪ ውስጥ ናቸው፤ ይህ ድምር ሁሉንም ያጠቃልላል።",
},
charts: {
nothing: "ለዚህ ማጣሪያ እስካሁን የሚታይ ነገር የለም።",
throughput: "የምዝገባ ፍሰት",
throughputSub: "ውሳኔዎች በተሰጡበት ወር ይቆጠራሉ",
submitted: "የቀረቡ",
approved: "የጸደቁ",
rejected: "ውድቅ",
documentsIssued: "የተሰጡ ሰነዶች",
documentsIssuedSub: "የመርከበኛ መጽሐፍ እና መሠረታዊ ሥልጠና",
seamanBook: "የመርከበኛ መጽሐፍ",
btc: "BTC",
certificatesIssued: "የተሰጡ የምስክር ወረቀቶች",
certificatesIssuedSub: "በተሰጡበት ቀን",
coc: "CoC",
cop: "CoP",
endorsement: "ማረጋገጫ",
verifications: "የተጠናቀቁ ማረጋገጫዎች",
verificationsSub: "ሕክምና እና የባህር አገልግሎት",
medical: "ሕክምና",
seaService: "የባህር አገልግሎት",
fees: "የተሰበሰቡ ክፍያዎች",
paid: "የተከፈለ ({{currency}})",
backlogAge: "ወረፋ በዕድሜ",
backlogAgeSub: "ክፍት ምዝገባዎች፣ ከቀረቡ ጀምሮ ያሉ ቀናት",
registrationStatus: "የምዝገባ ሁኔታ",
department: "ክፍል",
tier: "የማዕረግ ደረጃ",
tierSub: "የSTCW ገደብ",
ageBands: "የዕድሜ ክልሎች",
ageBandsSub: "የሥራ ዕድሜ ወሰኖች",
gender: "ጾታ",
nationalities: "ዜግነቶች",
topSlices: "ከፍተኛዎቹ፣ ቀሪው ተቧድኗል",
seaTimeBands: "የተረጋገጠ የባህር ጊዜ",
seaTimeBandsSub: "ሙሉው ሕዝብ፣ ስለዚህ ዜሮ ይታያል",
certificateExpiry: "የምስክር ወረቀት ማብቂያ",
medicalExpiry: "የሕክምና ማብቂያ",
documentExpiry: "የሰነድ ማብቂያ",
disjoint: "የተለያዩ ክልሎች",
documentKind: "የሰነድ ዓይነት",
documentStatus: "የሰነድ ሁኔታ",
certificateType: "የምስክር ወረቀት ዓይነት",
certificateStatus: "የምስክር ወረቀት ሁኔታ",
certifiedRanks: "የተመሰከረላቸው ማዕረጎች",
medicalFitness: "የሕክምና ብቃት",
medicalIssuers: "የሕክምና ሰጪዎች",
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
seaServiceRanks: "የባህር አገልግሎት ማዕረጎች",
seaServiceRanksSub: "ያገለገሉበት ኃላፊነት",
vesselTypes: "ያገለገሉባቸው የመርከብ ዓይነቶች",
flagStates: "ያገለገሉባቸው የባንዲራ አገሮች",
certApplications: "የምስክር ወረቀት ማመልከቻዎች",
certApplicationsSub: "የCoC፣ CoP እና ማረጋገጫ ወረፋዎች",
reviewerWorkload: "የገምጋሚ የሥራ ጫና",
reviewerWorkloadSub: "በእያንዳንዱ ባለሥልጣን የተወሰኑ ምዝገባዎች",
unassigned: "ያልተመደበ",
units: {
seafarers: "መርከበኞች",
registrations: "ምዝገባዎች",
requests: "ጥያቄዎች",
certificates: "የምስክር ወረቀቶች",
documents: "ሰነዶች",
medicals: "የሕክምና",
engagements: "ተሳትፎዎች",
applications: "ማመልከቻዎች",
decisions: "ውሳኔዎች",
},
},
tables: {
nothing: "የሚታይ ነገር የለም።",
viewAll: "ሁሉንም ይመልከቱ",
openQueue: "ወረፋ ክፈት",
openDesk: "ዴስክ ክፈት",
openRegistry: "መዝገብ ክፈት",
today: "ዛሬ",
days: "{{count}} ቀን",
unnamed: "ስም የሌለው",
withinDays: "በ{{count}} ቀን ውስጥ",
pending: "በወረፋ ላይ ያሉ ምዝገባዎች",
pendingSub: "አንጋፋው መጀመሪያ",
verifications: "ማረጋገጫ የሚጠብቁ መዝገቦች",
verificationsSub: "ሕክምና እና የባህር አገልግሎት፣ ረጅም የጠበቀው መጀመሪያ",
eligible: "ለCoC ብቁ፣ ያልተመሰከረላቸው",
eligibleSub: "ከ12 ወር በላይ የተረጋገጠ የባህር ጊዜ፣ ከፍተኛው መጀመሪያ",
expiringCertificates: "የሚያበቁ የምስክር ወረቀቶች",
expiringDocuments: "የሚያበቁ ሰነዶች",
expiringDocumentsSub: "የመርከበኛ መጽሐፍ እና BTC፣ በ{{count}} ቀን ውስጥ",
expiringMedicals: "የሚያበቁ የሕክምና ምስክር ወረቀቶች",
recent: "በቅርቡ የተመዘገቡ",
cols: {
applicant: "አመልካች",
seafarer: "መርከበኛ",
holder: "ባለቤት",
status: "ሁኔታ",
submitted: "የቀረበበት",
open: "ክፍት",
record: "መዝገብ",
waiting: "በመጠባበቅ",
certificate: "የምስክር ወረቀት",
document: "ሰነድ",
expires: "ያበቃል",
daysLeft: "ቀናት",
issuer: "ሰጪ",
fitness: "ብቃት",
department: "ክፍል",
nationality: "ዜግነት",
approved: "የጸደቀበት",
seaTime: "የባህር ጊዜ",
lastDischarge: "የመጨረሻ መልቀቅ",
holdsCop: "CoP",
},
holdsCopYes: "አለው",
holdsCopNo: "የለም",
expiresOn: "{{date}} ያበቃል",
management: "አስተዳደር",
operational: "ኦፕሬሽናል",
},
status: {
DRAFT: "ረቂቅ",
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ",
UNDER_REVIEW: "በግምገማ ላይ",
SUBMITTED: "የቀረበ",
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
APPROVED: "የጸደቀ",
REJECTED: "ውድቅ የተደረገ",
},
documentKind: {
SEAMAN_BOOK: "የመርከበኛ መጽሐፍ",
BTC_BASIC_TRAINING: "መሠረታዊ ሥልጠና (BTC)",
},
certificateType: {
CERTIFICATE_OF_COMPETENCY: "የብቃት ምስክር ወረቀት",
CERTIFICATE_OF_PROFICIENCY: "የክህሎት ምስክር ወረቀት",
ENDORSEMENT_SEAFARER: "ማረጋገጫ (CoC + GOC)",
ENDORSEMENT_COC: "ማረጋገጫ (CoC)",
ENDORSEMENT_GOC: "ማረጋገጫ (GOC)",
},
fitness: {
FIT: "ብቁ",
FIT_WITH_RESTRICTIONS: "በገደብ ብቁ",
UNFIT: "ብቁ ያልሆነ",
},
tier: { ABOVE: "ከላይ (አስተዳደር)", BELOW: "ከታች (ኦፕሬሽናል)" },
gender: { MALE: "ወንድ", FEMALE: "ሴት" },
recordType: { MEDICAL: "ሕክምና", SEA_SERVICE: "የባህር አገልግሎት" },
},
seafarerRegistry: {
title: "የመርከበኞች መዝገብ",
profileCount_one: "{{count}} መገለጫ",

View File

@@ -77,7 +77,7 @@ export const en = {
vesselFormBuilder: 'Vessel Form Builder',
vesselRegistrationReport: 'Vessel Registration Report',
ownershipTransferQueue: 'Ownership Transfer Queue',
logisticsHeadDashboard: 'Logistics Head Dashboard',
logisticsHeadDashboard: 'Logistics Department',
freightForwarderLicense: 'Freight Forwarder License',
shippingAgentLicense: 'Shipping Agent License',
combinedLicense: 'Combined License',
@@ -91,6 +91,7 @@ export const en = {
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerAnalytics: 'Seafarer Analytics',
seafarerRegistry: 'Seafarer Registry',
biometricEnrollment: 'Biometric Enrollment',
seafarerRegistrationQueue: 'Seafarer Registration Queue',
@@ -183,6 +184,261 @@ export const en = {
},
},
/**
* The logistics head's departmental dashboard. Scoped to the
* operator-licence family only — the wording says so throughout, because the
* authority-wide Operations Dashboard sits one nav item away and the two must
* never be mistaken for each other.
*/
logisticsHead: {
title: 'Logistics department',
subtitle:
'Operator licensing — every application in the department, who holds it, and what is late.',
loading: 'Loading the logistics department…',
errorTitle: 'The departments workload could not be loaded',
errorBody:
'The licence application queue did not answer. Nothing has changed — try again.',
typeFilter: 'Licence type',
allTypes: 'All operator licences',
openQueue: 'Open queue',
exportHint: 'Download the departmental report',
exportDone: 'Departmental report saved as {{filename}}.',
unassigned: 'Unassigned',
truncated:
'Showing the {{shown}} oldest open applications of {{total}}. Narrow by licence type for figures covering the whole department.',
truncatedHistory:
'The intake chart covers the {{shown}} most recent of {{total}} applications filed in this window, so its earliest months are understated. Choose a shorter window or one licence type.',
overdueTitle: 'Turnaround targets missed',
overdueBody_one:
'{{count}} application is past the turnaround target published for its licence type.',
overdueBody_other:
'{{count}} applications are past the turnaround target published for their licence type.',
overdueAction: 'Show them',
dispatchTitle: 'Work waiting to be handed out',
dispatchBody_one:
'{{count}} application has been filed for {{days}} days or more without being assigned to anyone.',
dispatchBody_other:
'{{count}} applications have been filed for {{days}} days or more without being assigned to anyone.',
dispatchAction: 'Dispatch now',
clear: {
title: 'The department is clear',
body:
'No operator-licence application is open — nothing to dispatch, nothing late, nothing waiting on a fee. Renewals and intake history are below.',
bodyFiltered:
'No {{type}} application is open — nothing to dispatch, nothing late, nothing waiting on a fee. Clear the licence-type filter to see the whole department.',
action: 'Open the full queue anyway',
},
periods: {
'3m': 'Last 3 months',
'6m': 'Last 6 months',
'12m': 'Last 12 months',
},
periodsShort: {
'3m': '3m',
'6m': '6m',
'12m': '12m',
},
kpis: {
open: 'Open',
openHint: 'median age {{days}}d · {{arrived}} arrived in {{window}}d',
overdueHintRecent_one: '{{count}} went late in the last {{window}}d',
overdueHintRecent_other: '{{count}} went late in the last {{window}}d',
dispatch: 'Awaiting dispatch',
dispatchHint: 'Filed, nobody assigned',
overdue: 'Overdue',
overdueHint: '{{count}} more at risk',
applicant: 'With applicant',
applicantHint: 'Corrections requested',
fee: 'Awaiting fee',
feeHint: 'Approved, unsettled',
issue: 'Ready to issue',
issueHint: 'Paid and confirmed',
},
columns: {
age: 'Age',
days: '{{count}}d',
sla: 'SLA',
officer: 'Officer',
neverSubmitted: 'Never submitted',
},
stages: {
intake: {
label: 'Intake',
hint: 'Filed and waiting to be handed to an officer',
},
review: {
label: 'Review',
hint: 'An officer is checking the file and its documents',
},
evaluation: {
label: 'Evaluation',
hint: 'Capital, staffing and eligibility being assessed',
},
inspection: {
label: 'Inspection',
hint: 'Premises visit booked, conducted or reported',
},
applicant: {
label: 'With applicant',
hint: 'Corrections requested — nothing moves until they respond',
},
payment: {
label: 'Fee',
hint: 'Approved, waiting for the licence fee to settle',
},
issuance: {
label: 'Issuance',
hint: 'Paid and confirmed — only the certificate is left',
},
hold: {
label: 'On hold',
hint: 'Parked by an officer, outside the normal flow',
},
},
board: {
title: 'Pipeline by stage',
subtitle:
'Open applications in the order they move through the department. Select a stage to open it in the queue.',
openCount: '{{count}} open',
lateChip: '{{count}} late',
clear: 'Clear',
medianAge: 'median {{days}}d · oldest {{oldest}}d',
heldTitle_one: '{{count}} parked on hold',
heldTitle_other: '{{count}} parked on hold',
heldHint: 'Outside the flow above — median {{days}}d parked. Not counted as a stage.',
bottleneckTitle: 'Slowest stage',
bottleneckBody:
'{{count}} applications are sitting in the {{stage}} stage, half of them for {{days}} days or more. This is where the department is losing the most time.',
},
ageBands: {
'0-3': '03 days',
'4-7': '47 days',
'8-14': '814 days',
'15-30': '1530 days',
'30+': 'Over 30 days',
},
charts: {
intake: {
title: 'Intake by month',
subtitle:
'Applications received each month, and where each months intake stands today',
empty: 'No logistics applications were filed in this window.',
issued: 'Issued',
rejected: 'Rejected',
open: 'Still open',
submitted: 'Received',
partialMonth:
'* The current month is still in progress, so its bar covers part of a month.',
},
ageing: {
title: 'Ageing profile',
subtitle: 'How long the open pipeline has been waiting, by band',
median: 'median {{days}}d',
empty: 'Nothing is open, so there is nothing ageing.',
tooltip: 'Open applications',
},
sla: {
title: 'Turnaround health',
subtitle_one: 'Measured over {{count}} open application with a target',
subtitle_other: 'Measured over {{count}} open applications with a target',
onTime: '{{rate}}% on time',
ring: 'Within target',
overdue: 'Overdue',
overdueHint: 'Past their target',
atRisk: 'At risk',
atRiskHint: 'Past 70% of the window',
untracked_one:
'{{count}} open application is of a type with no turnaround target set, so it is excluded from this figure.',
untracked_other:
'{{count}} open applications are of a type with no turnaround target set, so they are excluded from this figure.',
},
},
officers: {
title: 'Officer workload',
subtitle: 'Open files per officer, worst SLA position first',
count_one: '{{count}} officer',
count_other: '{{count}} officers',
emptyTitle: 'Nothing is in flight',
emptyBody: 'No open logistics application is assigned to anyone right now.',
officer: 'Officer',
load: 'Load',
active: 'Open',
oldest: 'Oldest',
loadTooltip: '{{onTrack}} on track · {{atRisk}} at risk · {{overdue}} overdue',
medianTick: '· team median {{count}}',
},
types: {
title: 'By licence type',
subtitle:
'Open work per operator licence, measured against that types own target',
emptyTitle: 'No open applications',
emptyBody: 'Every operator licence type is clear for the current filter.',
type: 'Licence type',
open: 'Open',
unassigned: 'Unassigned',
late: 'Late',
median: 'Median age',
target: 'Target',
targetDays: '{{count}}d',
noTarget: 'Not tracked',
inspected: 'Inspection required',
atRisk: '{{count}} at risk',
},
renewals: {
title: 'Renewal outlook',
subtitle: 'Live operator licences approaching expiry',
active: '{{count}} active',
within30: 'Expiring within 30 days',
within60: 'Expiring in 3160 days',
within90: 'Expiring in 6190 days',
expired: 'Already lapsed',
suspended: 'Suspended',
next: 'Next to expire',
none: 'No live operator licence expires in the next 90 days.',
inDays: '{{count}}d',
partial:
'The register returned only part of its rows, so these counts cover what was loaded rather than every issued licence.',
openRegister: 'Open the licence register',
},
worklists: {
dispatch: {
label: 'Awaiting dispatch',
empty: 'Every filed application has been handed to an officer.',
},
sla: {
label: 'SLA priority',
empty: 'Nothing is late or close to it.',
},
applicant: {
label: 'With applicant',
empty: 'No application is waiting on its applicant.',
},
issue: {
label: 'Ready to issue',
empty: 'Nothing is waiting for its certificate.',
},
},
scope: {
title: 'What this page covers',
body:
'Every figure here is the operator-licence family only — {{types}} licence types — and excludes seafarer certificates, seaman books and vessel registrations, which belong to other desks.',
filter: 'Current filter: {{type}} · intake window {{period}}.',
},
},
exam: {
title: 'Examinations',
subtitle: 'Manage exams, assign questions, and track results',
@@ -1809,6 +2065,247 @@ export const en = {
},
},
seafarerAnalytics: {
title: 'Seafarer services',
subtitleDefault:
'Registration, certification, documents, medical fitness and sea service at a glance.',
subtitleWindow:
'Registry-wide totals with a {{from}} {{to}} window on the trends.',
loadError: 'Could not load the report',
exportError: 'Could not export the register',
exportCutOff:
'Export cut off at {{count}} rows. Narrow the filter and export again.',
exportDone_one: 'Exported {{count}} seafarer.',
exportDone_other: 'Exported {{count}} seafarers.',
partialTitle: 'Partial figures',
partialBody:
'The register is larger than this report can scan in one pass, so every figure below covers only part of it. Narrow the filter for an exact answer.',
emptyTitle: 'No seafarers match this filter',
emptyFiltered:
'Nothing on the register matches the current filter. Clear it to see the whole register.',
emptyNone:
'No seafarers have registered yet. Entries appear here once a registration is filed.',
tabs: {
overview: 'Overview',
registration: 'Registration',
documents: 'Documents & certificates',
medical: 'Medical & sea service',
},
filters: {
period: 'Period',
periodPlaceholder: 'Last 12 months',
day: 'Day',
week: 'Week',
month: 'Month',
status: 'Registration status',
department: 'Department',
tier: 'Rank tier',
gender: 'Gender',
nationality: 'Nationality',
search: 'Search',
searchPlaceholder: 'Name, registration №, seafarer № or ID',
all: 'All',
clear: 'Clear',
export: 'Export CSV',
},
tiles: {
onRegister: 'Seafarers on the register',
onRegisterDetail: '{{total}} registrations filed · {{rejected}} rejected · {{draft}} draft',
onRegisterHint: 'Approved registrations. The whole register — not affected by the date filter.',
inPeriod: 'Registered in period',
inPeriodDetail: '{{previous}} in the previous period',
inPeriodHint:
'Registrations approved inside the selected window, against the equally long window before it. An approval is dated by its decision, not its submission.',
open: 'Open registrations',
openDetail: '{{biometrics}} awaiting biometrics · oldest {{oldest}} d',
openHint:
'Filed and still moving. Files awaiting biometrics are counted here but cannot be decided until the enrolment desk has captured them.',
processing: 'Processing time',
processingDetail: 'median · mean {{mean}} d · {{decided}} decided in period',
processingHint: 'Submission to decision. Only registrations that have been decided are counted.',
approvalRate: 'Approval rate',
approvalRateDetail: '{{approved}} approved · {{rejected}} rejected · {{filed}} filed in period',
approvalRateHint:
'Approved as a share of decided registrations. Drafts and files still in the queue are excluded.',
documents: 'Documents issued',
documentsDetail: '{{seamanBook}} Seaman Books · {{btc}} BTC · {{open}} open',
documentsHint:
'Seaman Book and Basic Training certificates. Issued is the whole book; open is every request not yet issued, rejected or cancelled.',
certificates: 'Live certificates',
certificatesDetail: '{{coc}} CoC · {{cop}} CoP · {{endorsement}} endorsements',
certificatesHint:
'Certificates of Competency, Proficiency and endorsements currently in force, across {{holders}} holders.',
expiring: 'Expiring in 30 days',
expiringDetail: '{{certificates}} certificates · {{documents}} documents · {{medicals}} medicals',
expiringHint:
'Everything a seafarer must renew inside a month, summed across the three shelves. Each figure is cumulative in its own chart.',
medical: 'Medical coverage',
medicalDetail: 'covered · {{lapsed}} lapsed · {{unfit}} unfit · {{toVerify}} to verify',
medicalHint:
'Seafarers holding at least one live, non-rejected medical. Lapsed means no live medical at all — the renewals chase list.',
seaTime: 'Verified sea time',
seaTimeDetail: 'average · {{over12}} past 12 months · {{withAny}} with any service',
seaTimeHint:
'Averaged over seafarers who have at least one verified engagement. Unverified claims are excluded — certificate eligibility rests on verified service only.',
eligible: 'Eligible, uncertified',
eligibleDetail: 'past 12 months of verified sea time with no live CoC',
eligibleHint:
'Registered seafarers past the STCW sea-time threshold who hold no live Certificate of Competency, whether or not they have applied.',
workforce: 'Workforce profile',
workforceDetail: 'avg age across {{known}} of {{total}} · {{female}} female · {{nationalities}} nationalities',
workforceHint:
'Date of birth and gender are optional on a registration, so each figure covers only the records that declared one.',
fees: 'Fees collected',
feesDetail: '{{pending}} outstanding · {{failed}} failed',
feesHint: 'Seaman Book and BTC fees, plus fees on certificate applications.',
feesMixedHint: 'Seafarer service fees are held in more than one currency; this total sums across them.',
},
charts: {
nothing: 'Nothing to show for this filter yet.',
throughput: 'Registration throughput',
throughputSub: 'decisions land in the month they were made',
submitted: 'Submitted',
approved: 'Approved',
rejected: 'Rejected',
documentsIssued: 'Documents issued',
documentsIssuedSub: 'Seaman Book and Basic Training',
seamanBook: 'Seaman Book',
btc: 'BTC',
certificatesIssued: 'Certificates issued',
certificatesIssuedSub: 'by issue date',
coc: 'CoC',
cop: 'CoP',
endorsement: 'Endorsement',
verifications: 'Verifications completed',
verificationsSub: 'medical and sea service',
medical: 'Medical',
seaService: 'Sea service',
fees: 'Fees collected',
paid: 'Paid ({{currency}})',
backlogAge: 'Backlog by age',
backlogAgeSub: 'open registrations, days since filing',
registrationStatus: 'Registration status',
department: 'Department',
tier: 'Rank tier',
tierSub: 'STCW limitation',
ageBands: 'Age bands',
ageBandsSub: 'working-age boundaries',
gender: 'Gender',
nationalities: 'Nationalities',
topSlices: 'top slices, rest grouped',
seaTimeBands: 'Verified sea time',
seaTimeBandsSub: 'whole population, so zero is visible',
certificateExpiry: 'Certificate expiry',
medicalExpiry: 'Medical expiry',
documentExpiry: 'Document expiry',
disjoint: 'disjoint bands',
documentKind: 'Document kind',
documentStatus: 'Document status',
certificateType: 'Certificate type',
certificateStatus: 'Certificate status',
certifiedRanks: 'Certified ranks',
medicalFitness: 'Medical fitness',
medicalIssuers: 'Medical issuers',
seaServiceVerification: 'Sea service verification',
seaServiceRanks: 'Sea service ranks',
seaServiceRanksSub: 'capacity served in',
vesselTypes: 'Vessel types served',
flagStates: 'Flag states served',
certApplications: 'Certificate applications',
certApplicationsSub: 'CoC, CoP and endorsement queues',
reviewerWorkload: 'Reviewer workload',
reviewerWorkloadSub: 'decided registrations per officer',
unassigned: 'Unassigned',
units: {
seafarers: 'Seafarers',
registrations: 'Registrations',
requests: 'Requests',
certificates: 'Certificates',
documents: 'Documents',
medicals: 'Medicals',
engagements: 'Engagements',
applications: 'Applications',
decisions: 'Decisions',
},
},
tables: {
nothing: 'Nothing to show.',
viewAll: 'View all',
openQueue: 'Open queue',
openDesk: 'Open desk',
openRegistry: 'Open registry',
today: 'Today',
days: '{{count}} d',
unnamed: 'Unnamed',
withinDays: 'within {{count}} days',
pending: 'Registrations in the queue',
pendingSub: 'oldest first',
verifications: 'Records awaiting verification',
verificationsSub: 'medical and sea service, longest waiting first',
eligible: 'Eligible for a CoC, not certified',
eligibleSub: 'past 12 months of verified sea time, most first',
expiringCertificates: 'Certificates expiring',
expiringDocuments: 'Documents expiring',
expiringDocumentsSub: 'Seaman Book and BTC, within {{count}} days',
expiringMedicals: 'Medical certificates expiring',
recent: 'Recently registered',
cols: {
applicant: 'Applicant',
seafarer: 'Seafarer',
holder: 'Holder',
status: 'Status',
submitted: 'Submitted',
open: 'Open',
record: 'Record',
waiting: 'Waiting',
certificate: 'Certificate',
document: 'Document',
expires: 'Expires',
daysLeft: 'Days',
issuer: 'Issuer',
fitness: 'Fitness',
department: 'Department',
nationality: 'Nationality',
approved: 'Approved',
seaTime: 'Sea time',
lastDischarge: 'Last discharge',
holdsCop: 'CoP',
},
holdsCopYes: 'Held',
holdsCopNo: 'None',
expiresOn: 'expires {{date}}',
management: 'Management',
operational: 'Operational',
},
status: {
DRAFT: 'Draft',
AWAITING_BIOMETRICS: 'Awaiting biometrics',
UNDER_REVIEW: 'Under review',
SUBMITTED: 'Submitted',
RESUBMIT_REQUIRED: 'Resubmit required',
APPROVED: 'Approved',
REJECTED: 'Rejected',
},
documentKind: {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training (BTC)',
},
certificateType: {
CERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
CERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
ENDORSEMENT_SEAFARER: 'Endorsement (CoC + GOC)',
ENDORSEMENT_COC: 'Endorsement (CoC)',
ENDORSEMENT_GOC: 'Endorsement (GOC)',
},
fitness: {
FIT: 'Fit',
FIT_WITH_RESTRICTIONS: 'Fit with restrictions',
UNFIT: 'Unfit',
},
tier: { ABOVE: 'Above (management)', BELOW: 'Below (operational)' },
gender: { MALE: 'Male', FEMALE: 'Female' },
recordType: { MEDICAL: 'Medical', SEA_SERVICE: 'Sea service' },
},
seafarerRegistry: {
title: 'Seafarer registry',
profileCount_one: '{{count}} profile',

View File

@@ -147,6 +147,16 @@ export const NAV_SECTIONS: NavSection[] = [
{
label: "nav.groupSeafarer",
items: [
{
// The overview for this group, and it sits first for the same reason
// /dashboard does globally: every figure on it is derived from the
// queues below, so it is where you start rather than somewhere you
// end up.
to: "/seafarer-analytics",
label: "nav.seafarerAnalytics",
icon: IconChartBar,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/seafarer-registry",
label: "nav.seafarerRegistry",

View File

@@ -31,6 +31,7 @@ import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfi
import { PickupDeskPage } from '../features/pickup/pages/PickupDeskPage';
import { PickupOfficesPage } from '../features/pickup/pages/PickupOfficesPage';
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
import { SeafarerAnalyticsPage } from '../features/seafarer-analytics/pages/SeafarerAnalyticsPage';
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
import { BtcQueuePage, SeamanBookQueuePage } from '../features/seafarer-document-review/pages/SeafarerDocumentQueuePage';
@@ -110,6 +111,7 @@ const router = createBrowserRouter([
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], <PickupDeskPage />) },
{ path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], <PickupOfficesPage />) },
{ path: 'seafarer-analytics', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerAnalyticsPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
// Seafarer registration is not a licence: own queue, own review.

View File

@@ -1,6 +1,7 @@
import { baseApi } from '../../base-api';
import type {
AdminDashboardAnalytics,
AdminDashboardAnalyticsArgs,
ApplicantDashboardSummary,
ExamStateView,
AppNotification,
@@ -821,11 +822,14 @@ export const licensingApi = baseApi
getAdminDashboardAnalytics: builder.query<
AdminDashboardAnalytics,
{ period?: string } | void
AdminDashboardAnalyticsArgs | void
>({
query: (args) => ({
url: '/license-application-review/dashboard-analytics',
params: args?.period ? { period: args.period } : undefined,
params: dropEmpty({
period: args?.period,
familyKind: args?.familyKind,
}),
}),
providesTags: () => [listTag('ApplicationQueue'), listTag('License')],
}),

View File

@@ -652,6 +652,12 @@ export interface AppNotification {
/** Server-side queue filters. Mirrors ApplicationQueueFilterDto in the API. */
export interface QueueFilter {
licenseTypeId?: string;
/**
* Scope to one business family. A departmental screen owns exactly one, and
* asking the server for it beats pulling every family and discarding the
* rest — which made a page's row cap bite long before its own volume did.
*/
familyKind?: FamilyKind;
search?: string;
status?: LicenseStatus[];
kind?: ApplicationKind;
@@ -1041,6 +1047,14 @@ export interface ApplicantDashboardSummary {
}>;
}
/** Query for {@link AdminDashboardAnalytics}. */
export interface AdminDashboardAnalyticsArgs {
/** "7d" | "30d" | "6m" (default) | "1y". */
period?: string;
/** Scopes every figure to one department's family. Omit for the authority. */
familyKind?: FamilyKind;
}
export interface AdminDashboardAnalytics {
kpis: {
totalApplications: number;

View File

@@ -1,3 +1,4 @@
export * from './seafarer.types';
export * from './seafarer-report.types';
export * from './seafarer-api';
export * from './seafarer.helpers';

View File

@@ -8,8 +8,21 @@ import type {
SeaTimeSummary,
SeafarerStatus,
} from './seafarer.types';
import type { SeafarerReport, SeafarerReportQuery } from './seafarer-report.types';
const TAGS = ['SeaServiceRecord', 'MedicalCertificate'] as const;
/**
* The report reads across every shelf a seafarer has, so it is tagged with all
* of them: approving a registration, issuing a seaman book or verifying a
* medical certificate all move figures on the dashboard, and a stale KPI beside
* a fresh queue is worse than no KPI.
*/
const TAGS = [
'SeaServiceRecord',
'MedicalCertificate',
'SeafarerRegistration',
'SeafarerDocument',
'License',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -22,6 +35,31 @@ export const seafarerApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })
.injectEndpoints({
endpoints: (builder) => ({
// --------------------------------------------------------------- report
/**
* The whole seafarer services dashboard in one call — KPIs, time series,
* breakdowns and worklists. Backoffice only
* (`can:View:seafarer-registry`).
*
* Array filters are passed as arrays, not joined strings: the API accepts
* both the repeated and the comma-separated form, and `params`
* serialises the repeated one.
*/
getSeafarerReport: builder.query<SeafarerReport, SeafarerReportQuery | void>({
query: (params) => ({
url: '/seafarer-registry/report',
params: params ?? undefined,
}),
providesTags: () => [
listTag('SeafarerRegistration'),
listTag('SeafarerDocument'),
listTag('SeaServiceRecord'),
listTag('MedicalCertificate'),
listTag('License'),
],
}),
// ---------------------------------------------------------- sea service
getMySeaServiceRecords: builder.query<SeaServiceRecord[], void>({
query: () => ({ url: '/sea-service-records/mine' }),
@@ -181,6 +219,7 @@ export const seafarerApi = baseApi
});
export const {
useGetSeafarerReportQuery,
useGetPendingSeaServiceQuery,
useGetPendingMedicalQuery,
useVerifySeaServiceRecordMutation,

View File

@@ -0,0 +1,377 @@
import type { BreakdownItem, ReportGranularity } from '../vessel/vessel.types';
import type {
Gender,
RankTier,
SeafarerRegistrationStatus,
} from '../seafarer-registration/seafarer-registration.types';
import type {
SeafarerDocumentKind,
SeafarerDocumentStatus,
} from '../seafarer-document/seafarer-document.types';
import type { MedicalFitness, SeafarerRecordStatus } from './seafarer.types';
// ------------------------------------------------- seafarer services report
//
// One call fills the whole seafarer services dashboard. Every numeric field
// here is already a real number — the API casts the Postgres `numeric` strings
// before it answers.
//
// `BreakdownItem` and `ReportGranularity` are shared with the vessel report
// rather than redeclared: the two dashboards render the same chart components
// against the same server-side helpers, and two structurally identical types
// would drift the first time one of them gained a field.
/** The licence types that are seafarer certificates. */
export type SeafarerCertificateTypeKey =
| 'CERTIFICATE_OF_COMPETENCY'
| 'CERTIFICATE_OF_PROFICIENCY'
| 'ENDORSEMENT_SEAFARER'
| 'ENDORSEMENT_COC'
| 'ENDORSEMENT_GOC';
export interface SeafarerReportQuery {
/** Bounds the time series and the "in period" figures only. */
from?: string;
to?: string;
granularity?: ReportGranularity;
status?: SeafarerRegistrationStatus[];
department?: string[];
tier?: RankTier[];
gender?: Gender[];
nationality?: string[];
search?: string;
expiringWithinDays?: number;
/** Slices kept per high-cardinality chart; the tail collapses into "Other". */
topN?: number;
tableLimit?: number;
}
export interface RegistryKpis {
total: number;
approved: number;
rejected: number;
draft: number;
awaitingBiometrics: number;
underReview: number;
submitted: number;
resubmitRequired: number;
/** Filed and still moving: the four non-draft, non-decided statuses. */
pending: number;
/** Approvals dated by their decision, not their submission. */
registeredInPeriod: number;
registeredInPreviousPeriod: number;
/** Null when the previous period was empty — no basis to compare. */
changePct: number | null;
}
export interface RegistrationPipelineKpis {
submittedInPeriod: number;
decidedInPeriod: number;
/** Approved over settled. Null while nothing has been decided. */
approvalRatePct: number | null;
avgProcessingDays: number | null;
medianProcessingDays: number | null;
backlog: number;
oldestPendingDays: number | null;
avgPendingDays: number | null;
/** The one backlog a reviewer cannot clear alone — it needs the desk. */
awaitingBiometrics: number;
}
export interface DemographicsKpis {
avgAgeYears: number | null;
/** How many records the age average actually covers. */
ageKnownFor: number;
male: number;
female: number;
genderKnownFor: number;
femalePct: number | null;
nationalities: number;
departments: number;
}
export interface SeafarerDocumentKpis {
total: number;
issued: number;
pending: number;
rejected: number;
cancelled: number;
awaitingPayment: number;
scheduled: number;
seamanBookIssued: number;
seamanBookPending: number;
btcIssued: number;
btcPending: number;
issuedInPeriod: number;
expired: number;
/** Cumulative: a book due in 11 days is inside all three. */
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
avgIssuanceDays: number | null;
medianIssuanceDays: number | null;
}
export interface SeafarerCertificateKpis {
total: number;
active: number;
expired: number;
suspended: number;
cancelled: number;
cocActive: number;
copActive: number;
/** The three endorsement keys summed — one product to a reader. */
endorsementActive: number;
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
/** Distinct holders of a live certificate. */
holders: number;
applicationsInProgress: number;
applicationsApprovalRatePct: number | null;
applicationsMedianProcessingDays: number | null;
}
export interface MedicalKpis {
total: number;
valid: number;
expired: number;
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
fit: number;
fitWithRestrictions: number;
unfit: number;
verified: number;
pendingVerification: number;
rejected: number;
/** Seafarers holding at least one live, non-rejected medical. */
seafarersCovered: number;
/** Seafarers on the shelf with no live medical at all — the chase list. */
seafarersLapsed: number;
}
export interface SeaServiceKpis {
records: number;
verified: number;
pendingVerification: number;
rejected: number;
/** Verified days only — unverified claims are not recognised sea time. */
totalSeaDays: number;
avgSeaDaysPerSeafarer: number | null;
medianSeaDaysPerSeafarer: number | null;
seafarersWithService: number;
/** The STCW headline threshold: who is a year of sea time in. */
seafarersOverTwelveMonths: number;
/** Past the threshold with no live CoC — the intake nobody has filed yet. */
eligibleUncertified: number;
vesselsServed: number;
}
export interface SeafarerRevenueKpis {
currency: string;
/** True when more than one currency was summed — warn rather than total. */
mixedCurrency: boolean;
paid: number;
pending: number;
paidCount: number;
pendingCount: number;
failedCount: number;
refundedCount: number;
}
/** Bucketed series. `bucket` is an ISO date; the window is zero-filled. */
export interface RegistrationTrendBucket {
bucket: string;
submitted: number;
approved: number;
rejected: number;
}
export interface DocumentTrendBucket {
bucket: string;
seamanBook: number;
btc: number;
total: number;
}
export interface CertificateTrendBucket {
bucket: string;
coc: number;
cop: number;
endorsement: number;
total: number;
}
export interface VerificationTrendBucket {
bucket: string;
medical: number;
seaService: number;
}
export interface SeafarerRevenueBucket {
bucket: string;
amount: number;
count: number;
}
export interface PendingRegistrationRow {
id: string;
registrationNumber: string;
applicantName: string | null;
department: string | null;
tier: RankTier | null;
status: SeafarerRegistrationStatus;
submittedAt: string | null;
daysOpen: number;
}
export interface RecentSeafarerRegistrationRow {
id: string;
registrationNumber: string;
seafarerNumber: string | null;
applicantName: string | null;
department: string | null;
tier: RankTier | null;
nationality: string | null;
decidedAt: string | null;
}
export interface ExpiringDocumentRow {
id: string;
kind: SeafarerDocumentKind;
documentNumber: string | null;
requestNumber: string;
registrationNumber: string | null;
holderName: string | null;
expiryDate: string;
/** 0 means it expires today, which still counts as live. */
daysToExpiry: number;
}
export interface ExpiringSeafarerCertificateRow {
id: string;
certificateNumber: string;
typeKey: SeafarerCertificateTypeKey | string;
scopeKey: string | null;
rank: string | null;
holderUserId: string;
holderName: string | null;
registrationNumber: string | null;
expiryDate: string;
daysToExpiry: number;
}
export interface ExpiringMedicalRow {
id: string;
profileId: string;
certificateNumber: string | null;
issuerName: string;
fitnessStatus: MedicalFitness;
status: SeafarerRecordStatus;
holderName: string | null;
registrationNumber: string | null;
expiryDate: string;
daysToExpiry: number;
}
/** A registered seafarer past the CoC sea-time threshold with no live CoC. */
export interface EligibleUncertifiedRow {
id: string;
registrationNumber: string;
seafarerNumber: string | null;
applicantName: string | null;
department: string | null;
tier: RankTier | null;
/** Verified sea days. */
seaDays: number;
holdsCop: boolean;
lastDischargeDate: string | null;
}
export interface PendingVerificationRow {
id: string;
recordType: 'MEDICAL' | 'SEA_SERVICE';
holderName: string | null;
registrationNumber: string | null;
/** Issuer + number, or vessel + rank — enough to recognise the record. */
summary: string;
submittedAt: string;
daysWaiting: number;
}
export interface SeafarerReport {
generatedAt: string;
/** True when the register passed the API's scan cap — figures are partial. */
truncated: boolean;
filters: {
from: string;
to: string;
granularity: ReportGranularity;
expiringWithinDays: number;
topN: number;
tableLimit: number;
status: SeafarerRegistrationStatus[] | null;
department: string[] | null;
tier: RankTier[] | null;
gender: Gender[] | null;
nationality: string[] | null;
search: string | null;
};
kpis: {
registry: RegistryKpis;
pipeline: RegistrationPipelineKpis;
demographics: DemographicsKpis;
documents: SeafarerDocumentKpis;
certificates: SeafarerCertificateKpis;
medical: MedicalKpis;
seaService: SeaServiceKpis;
revenue: SeafarerRevenueKpis;
};
timeSeries: {
registrations: RegistrationTrendBucket[];
documents: DocumentTrendBucket[];
certificates: CertificateTrendBucket[];
verifications: VerificationTrendBucket[];
revenue: SeafarerRevenueBucket[];
};
breakdowns: {
byRegistrationStatus: BreakdownItem[];
byDepartment: BreakdownItem[];
byTier: BreakdownItem[];
byGender: BreakdownItem[];
byAgeBand: BreakdownItem[];
byNationality: BreakdownItem[];
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
byReviewOfficer: BreakdownItem[];
/** Open registrations by how long they have waited. */
byPendingAge: BreakdownItem[];
byDocumentKind: BreakdownItem[];
byDocumentStatus: BreakdownItem[];
byCertificateType: BreakdownItem[];
byCertificateStatus: BreakdownItem[];
byCertificateRank: BreakdownItem[];
byCertApplicationStatus: BreakdownItem[];
byMedicalFitness: BreakdownItem[];
byMedicalStatus: BreakdownItem[];
byMedicalIssuer: BreakdownItem[];
bySeaServiceStatus: BreakdownItem[];
bySeaServiceRank: BreakdownItem[];
bySeaServiceVesselType: BreakdownItem[];
bySeaServiceFlagState: BreakdownItem[];
/** Banded over the whole population, so zero sea time is visible. */
bySeaDaysBand: BreakdownItem[];
};
tables: {
pendingRegistrations: PendingRegistrationRow[];
recentRegistrations: RecentSeafarerRegistrationRow[];
expiringDocuments: ExpiringDocumentRow[];
expiringCertificates: ExpiringSeafarerCertificateRow[];
expiringMedicals: ExpiringMedicalRow[];
pendingVerifications: PendingVerificationRow[];
eligibleUncertified: EligibleUncertifiedRow[];
};
}
/** Document status kept for the filter/legend labels the dashboard renders. */
export type { SeafarerDocumentStatus };

View File

@@ -31,6 +31,7 @@ export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";
export * from "./lib/data/StatTile";
export * from "./lib/data/report-format";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";

View File

@@ -53,6 +53,16 @@ interface AdvancedTableProps<T> {
onRowClick?: (row: T) => void;
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
title?: ReactNode;
/**
* Width below which the grid scrolls horizontally instead of compressing.
*
* The 480 default suits the three- and four-column grids most screens show.
* A wider table needs a wider floor: squeezed past it, Mantine ellipsises
* badge content, so a status column collapses to "SU…" — unreadable, and
* unlike a scrollbar it gives no hint that anything was hidden. Pass the
* table's real minimum when it carries more than about five columns.
*/
minWidth?: number;
/** Search box, filters, export — rendered top-right before Refresh/View. */
toolbar?: ReactNode;
}
@@ -88,6 +98,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
onRowClick,
title,
toolbar,
minWidth = 480,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -176,7 +187,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Group>
</Group>
<Table.ScrollContainer minWidth={480}>
<Table.ScrollContainer minWidth={minWidth}>
<Table verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>

View File

@@ -0,0 +1,285 @@
/**
* Presentation rules shared by the authority's dashboards (the vessel
* register report and the seafarer services report).
*
* Kept here rather than in either feature folder because the two screens are
* meant to read as one product: the same em dash for "no answer", the same
* palette assigned in the same order, the same muted treatment for the
* bookkeeping slices. Two copies would drift the first time one of them gained
* a colour.
*
* Nothing in this file knows what a vessel or a seafarer is — the
* domain-specific labels stay in each feature's own `report-format.ts`, which
* re-exports this module so a component has one import.
*/
import { ethMonthName, toEthDateTime } from '@ema-platform/shared';
/** One slice of a breakdown chart, as every report's API returns it. */
export interface ReportBreakdownItem {
key: string;
label: string;
count: number;
percentage: number;
}
/** Nothing measurable is not zero — an em dash says so without lying. */
export const DASH = '—';
/**
* A figure the API may legitimately have no answer for.
*
* An average is null on an empty register and a rate is null until something
* has been decided; rendering either as 0 would report a fleet that weighs
* nothing and a service that approves nobody.
*/
export function formatNumber(
value: number | null | undefined,
options: { decimals?: number; suffix?: string } = {},
): string {
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
const text = value.toLocaleString(undefined, {
minimumFractionDigits: options.decimals ?? 0,
maximumFractionDigits: options.decimals ?? 0,
});
return options.suffix ? `${text}${options.suffix}` : text;
}
export function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined
? DASH
: `${formatNumber(value, { decimals: 1 })}%`;
}
export function formatMoney(value: number, currency: string): string {
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
}
/** A signed delta for the change-vs-previous chip. */
export function formatDelta(value: number | null): string {
if (value === null) return DASH;
const sign = value > 0 ? '+' : '';
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
}
export function deltaColor(value: number | null): string {
if (value === null || value === 0) return 'gray';
return value > 0 ? 'teal' : 'red';
}
/**
* Days as a readable duration.
*
* Sea time and processing times are both days on the wire, but "487 d" is not
* a figure anyone reads as "a year and four months", which is the unit
* certificate eligibility is argued in.
*/
export function formatDays(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
if (value < 31) return `${formatNumber(value)} d`;
if (value < 365) return `${formatNumber(value / 30.44, { decimals: 1 })} mo`;
return `${formatNumber(value / 365.25, { decimals: 1 })} yr`;
}
/** Cumulative expiry counts, as the disjoint bands a chart can stack. */
export interface CumulativeExpiry {
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
}
/**
* An API's expiry counts are cumulative — a certificate due in eleven days is
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
* them. Stacked side by side in a chart that reads as three separate groups,
* so they are differenced into disjoint bands first.
*/
export function expiryBands(
counts: CumulativeExpiry,
): Array<{ label: string; count: number }> {
const { expiringIn30, expiringIn60, expiringIn90 } = counts;
return [
{ label: 'Within 30 days', count: expiringIn30 },
// Math.max guards against a server that ever answers non-monotonically —
// a negative bar is worse than a zero one.
{ label: '3160 days', count: Math.max(0, expiringIn60 - expiringIn30) },
{ label: '6190 days', count: Math.max(0, expiringIn90 - expiringIn60) },
];
}
/** Red inside a week, orange inside a month, otherwise unremarkable. */
export function expiryUrgency(daysToExpiry: number): string {
if (daysToExpiry <= 7) return 'red';
if (daysToExpiry <= 30) return 'orange';
return 'gray';
}
/**
* Officer ids are IAM uuids, which make useless axis labels. Until the
* dashboards have a name lookup, shorten them and keep "UNASSIGNED" readable.
*/
export function officerLabel(key: string): string {
if (key === 'UNASSIGNED') return 'Unassigned';
return key.length > 8 ? `${key.slice(0, 8)}` : key;
}
/**
* Chart colours, assigned by position so a slice keeps its colour between
* renders. Mantine's palette rather than invented hex codes, so the charts
* follow the theme the rest of the app is built on.
*/
const PALETTE = [
'var(--mantine-color-blue-6)',
'var(--mantine-color-teal-6)',
'var(--mantine-color-orange-6)',
'var(--mantine-color-grape-6)',
'var(--mantine-color-cyan-6)',
'var(--mantine-color-lime-7)',
'var(--mantine-color-pink-6)',
'var(--mantine-color-indigo-6)',
];
const MUTED = 'var(--mantine-color-gray-5)';
/**
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
* always take the muted colour instead of competing with the real categories
* for one of the bright ones.
*/
export function sliceColor(item: ReportBreakdownItem, index: number): string {
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
return PALETTE[index % PALETTE.length];
}
/**
* Bucket keys are ISO dates; the axis wants something a human reads.
*
* Under Amharic the tick is the Ethiopian month (and day), the way every other
* date in the app already renders through `dateDisplayer` — a chart whose axis
* says "Sep 2026" beside a table that says "መስከረም 2019" is two calendars on one
* screen. Bucket keys are UTC midnights, so the day is embedded at UTC noon
* first: `toEthDateTime` reads local Y/M/D, and in a positive-offset timezone
* a UTC midnight is still the previous local day.
*/
export function formatBucket(
bucket: string,
granularity: 'DAY' | 'WEEK' | 'MONTH',
language = 'en',
): string {
const date = new Date(bucket);
if (Number.isNaN(date.getTime())) return bucket;
if (language.startsWith('am')) {
const local = new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
12,
);
const eth = toEthDateTime(local);
const month = ethMonthName(local);
return granularity === 'MONTH'
? `${month} ${eth.year}`
: `${month} ${eth.date}`;
}
if (granularity === 'MONTH') {
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
return date.toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
/** The default window the APIs apply when none is given: the last 12 months. */
export function defaultRange(now: Date): [Date, Date] {
const from = new Date(
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
);
return [from, now];
}
export const ISO_DAY_LENGTH = 10;
export const toIsoDay = (date: Date): string =>
date.toISOString().slice(0, ISO_DAY_LENGTH);
/**
* The filter state as URL search params, so a filtered dashboard is a
* shareable link rather than something the next person has to rebuild.
*
* Empty arrays and blank strings are dropped rather than serialised, which
* keeps an untouched dashboard's URL clean and lets the API apply its own
* defaults instead of being handed an empty filter to honour.
*/
export function queryToSearchParams(query: object): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
params.set(key, value.join(','));
} else {
params.set(key, String(value));
}
}
return params;
}
/**
* The inverse, for restoring state from a shared link.
*
* The key lists are the caller's, because they are the report's filter
* contract: a key this report does not understand is dropped rather than
* forwarded to fail the API's validation pipe.
*/
export function searchParamsToQuery<T>(
params: URLSearchParams,
keys: {
arrays: readonly string[];
numbers: readonly string[];
strings: readonly string[];
},
): T {
const query: Record<string, unknown> = {};
for (const key of keys.arrays) {
const raw = params.get(key);
if (raw) query[key] = raw.split(',').filter(Boolean);
}
for (const key of keys.numbers) {
const raw = params.get(key);
// An unparseable number in a hand-edited URL is ignored rather than sent
// on to fail the API's validation pipe.
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
query[key] = Number(raw);
}
}
for (const key of keys.strings) {
const raw = params.get(key);
if (raw) query[key] = raw;
}
const granularity = params.get('granularity');
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
query.granularity = granularity;
}
return query as T;
}
/**
* The multi-select options a filter offers, taken from the breakdown the last
* response carried — there is no lookup endpoint for flag states, ports or
* nationalities, and the register is the only place that knows which ones are
* in use.
*
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
* filter the register down to.
*/
export function optionsFrom(items: ReportBreakdownItem[] | undefined): string[] {
return (items ?? [])
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
.map((item) => item.key);
}