mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 04:48:19 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
110
apps/backoffice/src/app/charts/ChartCard.tsx
Normal file
110
apps/backoffice/src/app/charts/ChartCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 department’s 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 type’s 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 month’s 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 month’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 31–60 days')}
|
||||
value={radar.within60}
|
||||
color="orange"
|
||||
onClick={onOpenRegister}
|
||||
/>
|
||||
<RadarRow
|
||||
label={t('logisticsHead.renewals.within90', 'Expiring in 61–90 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>
|
||||
);
|
||||
}
|
||||
@@ -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 type’s 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
220
apps/backoffice/src/app/features/logistics-head/report.ts
Normal file
220
apps/backoffice/src/app/features/logistics-head/report.ts
Normal 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 31–60 days', renewals.within60]);
|
||||
rows.push(['Expiring in 61–90 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: '31–60 days', count: 4 },
|
||||
{ label: '61–90 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: '31–60 days', count: 0 },
|
||||
{ label: '61–90 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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||
{ label: '61–90 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,
|
||||
});
|
||||
|
||||
@@ -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": "0–3 ቀናት",
|
||||
"4-7": "4–7 ቀናት",
|
||||
"8-14": "8–14 ቀናት",
|
||||
"15-30": "15–30 ቀናት",
|
||||
"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: "በ31–60 ቀናት የሚያልቁ",
|
||||
within90: "በ61–90 ቀናት የሚያልቁ",
|
||||
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}} መገለጫ",
|
||||
|
||||
@@ -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 department’s 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': '0–3 days',
|
||||
'4-7': '4–7 days',
|
||||
'8-14': '8–14 days',
|
||||
'15-30': '15–30 days',
|
||||
'30+': 'Over 30 days',
|
||||
},
|
||||
|
||||
charts: {
|
||||
intake: {
|
||||
title: 'Intake by month',
|
||||
subtitle:
|
||||
'Applications received each month, and where each month’s 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 type’s 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 31–60 days',
|
||||
within90: 'Expiring in 61–90 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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './seafarer.types';
|
||||
export * from './seafarer-report.types';
|
||||
export * from './seafarer-api';
|
||||
export * from './seafarer.helpers';
|
||||
|
||||
@@ -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,
|
||||
|
||||
377
libs/api/src/lib/features/seafarer/seafarer-report.types.ts
Normal file
377
libs/api/src/lib/features/seafarer/seafarer-report.types.ts
Normal 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 };
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
285
libs/ui/src/lib/data/report-format.ts
Normal file
285
libs/ui/src/lib/data/report-format.ts
Normal 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: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||
{ label: '61–90 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);
|
||||
}
|
||||
Reference in New Issue
Block a user