diff --git a/apps/backoffice/src/app/charts/ChartCard.tsx b/apps/backoffice/src/app/charts/ChartCard.tsx new file mode 100644 index 000000000..6f9872df5 --- /dev/null +++ b/apps/backoffice/src/app/charts/ChartCard.tsx @@ -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 ( + + + + {ChartIcon && ( + + + + )} +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+
+ {badge} +
+ + {empty ? ( +
+ + {emptyText ?? 'No data available for this chart yet.'} + +
+ ) : ( + + + {children as never} + + + )} + + {!empty && footnote && ( + + {footnote} + + )} +
+ ); +} diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx index 17427b9f8..2389a71e4 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx @@ -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 ( - - - - {Icon && ( - - - - )} -
- - {title} - - {subtitle && ( - - {subtitle} - - )} -
-
- {badge} -
- - {empty ? ( -
- - {emptyText ?? 'No data available for this chart yet.'} - -
- ) : ( - - - {children as never} - - - )} -
- ); -} +import { + AXIS_STYLE, + ChartCard, + GRID_COLOR, + TOOLTIP_BOX_STYLE, +} from '../../../../charts/ChartCard'; const CATEGORY_NAMES: Record = { CARGO_FREIGHT: 'Cargo & Freight', @@ -213,7 +147,9 @@ export function DashboardCharts({ [ + // 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({ [`${val} applications`, name]} + formatter={(val, name) => [`${val} applications`, name]} /> [`${val} applications`, 'Volume']} + formatter={(val) => [`${val} applications`, 'Volume']} /> {categoryData.map((entry, index) => ( @@ -453,7 +389,7 @@ export function DashboardCharts({ /> [ + formatter={(val, name) => [ `${val} applications`, name === 'onScheduleCount' ? 'On Schedule' : 'SLA Overdue', ]} diff --git a/apps/backoffice/src/app/features/license-review/export.ts b/apps/backoffice/src/app/features/license-review/export.ts index 6bdc0f0c9..0010700a4 100644 --- a/apps/backoffice/src/app/features/license-review/export.ts +++ b/apps/backoffice/src/app/features/license-review/export.ts @@ -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; diff --git a/apps/backoffice/src/app/features/logistics-head/logistics-metrics.test.ts b/apps/backoffice/src/app/features/logistics-head/logistics-metrics.test.ts new file mode 100644 index 000000000..6c41b8a60 --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/logistics-metrics.test.ts @@ -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 { + 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 { + 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 & Pick, + ): 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); + }); +}); diff --git a/apps/backoffice/src/app/features/logistics-head/logistics-metrics.ts b/apps/backoffice/src/app/features/logistics-head/logistics-metrics.ts new file mode 100644 index 000000000..d2e4d6e8a --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/logistics-metrics.ts @@ -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> = { + 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, +): 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, + 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> = [ + { 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(); + + 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(); + + 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); + } +} diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/LogisticsCharts.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/LogisticsCharts.tsx new file mode 100644 index 000000000..d7c856b9b --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/LogisticsCharts.tsx @@ -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 = { + 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 ( + + {periodLabel} + + } + 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.', + )} + > + + + + + + + + {data.map((cohort) => ( + + ))} + + + {data.map((cohort) => ( + + ))} + + + {data.map((cohort) => ( + + ))} + + {/* 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. */} + + + + ); +} + +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 ( + + {t('logisticsHead.charts.ageing.median', { + days: medianDays, + defaultValue: 'median {{days}}d', + })} + + } + empty={empty} + emptyText={t( + 'logisticsHead.charts.ageing.empty', + 'Nothing is open, so there is nothing ageing.', + )} + > + + + + + [ + value, + t('logisticsHead.charts.ageing.tooltip', 'Open applications'), + ]} + /> + + {data.map((bucket) => ( + + ))} + + + + ); +} + +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, + ) => ( + + + + + {label} + + + + {value} + + + {hint} + + + ); + + return ( + + + + + + +
+ + {t('logisticsHead.charts.sla.title', 'Turnaround health')} + + + {t('logisticsHead.charts.sla.subtitle', { + count: totals.slaTracked, + defaultValue: 'Measured over {{count}} open applications with a target', + })} + +
+
+
+ +
+ + + + {rate}% + + + {t('logisticsHead.charts.sla.ring', 'Within target')} + + +
+ } + /> + + + + {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, + )} + + + {untracked > 0 && ( + + + + {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.', + })} + + + )} +
+ ); +} diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/PipelineBoard.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/PipelineBoard.tsx new file mode 100644 index 000000000..25ad3da48 --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/PipelineBoard.tsx @@ -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 ( + + + + + {t('logisticsHead.board.title', 'Pipeline by stage')} + + + {t( + 'logisticsHead.board.subtitle', + 'Open applications in the order they move through the department. Select a stage to open it in the queue.', + )} + + + + {t('logisticsHead.board.openCount', { + count: stages.reduce((sum, stage) => sum + stage.count, 0), + defaultValue: '{{count}} open', + })} + + + + {bottleneck && ( + } + 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.', + })} + + )} + + + {flow.map((stage) => { + const meta = STAGE_META[stage.stage]; + const StageIcon = meta.icon; + const empty = stage.count === 0; + return ( + + onOpenStage(stage.stage)} + aria-label={`${stageLabel(t, stage.stage)}: ${stage.count}`} + style={{ display: 'block', height: '100%' }} + > + + + + + + + + + + {stageLabel(t, stage.stage)} + + + + + {stage.count} + + {stage.overdue > 0 && ( + + {t('logisticsHead.board.lateChip', { + count: stage.overdue, + defaultValue: '{{count}} late', + })} + + )} + + + {/* A share-of-pipeline bar: the numbers alone make it hard + to see at a glance which stage is carrying the load. */} + + + + + + {empty + ? t('logisticsHead.board.clear', 'Clear') + : t('logisticsHead.board.medianAge', { + days: stage.medianDays, + defaultValue: 'median {{days}}d · oldest {{oldest}}d', + oldest: stage.oldestDays, + })} + + + + + ); + })} + + + {held && held.count > 0 && ( + onOpenStage('hold')} + mt="sm" + w="100%" + aria-label={`${stageLabel(t, 'hold')}: ${held.count}`} + > + + + + + + +
+ + {t('logisticsHead.board.heldTitle', { + count: held.count, + defaultValue: '{{count}} parked on hold', + })} + + + {t('logisticsHead.board.heldHint', { + days: held.medianDays, + defaultValue: + 'Outside the flow above — median {{days}}d parked. Not counted as a stage.', + })} + +
+
+ + {held.overdue > 0 && ( + + {t('logisticsHead.board.lateChip', { + count: held.overdue, + defaultValue: '{{count}} late', + })} + + )} + + +
+
+
+ )} +
+ ); +} diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/RenewalPanel.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/RenewalPanel.tsx new file mode 100644 index 000000000..140be71bd --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/RenewalPanel.tsx @@ -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 ( + + + + + + +
+ + {t('logisticsHead.renewals.title', 'Renewal outlook')} + + + {t( + 'logisticsHead.renewals.subtitle', + 'Live operator licences approaching expiry', + )} + +
+
+ + {t('logisticsHead.renewals.active', { + count: radar.active, + defaultValue: '{{count}} active', + })} + +
+ + + + + + + {radar.suspended > 0 && ( + + )} + + + + + + {t('logisticsHead.renewals.next', 'Next to expire')} + + + {radar.upcoming.length === 0 ? ( + + {t( + 'logisticsHead.renewals.none', + 'No live operator licence expires in the next 90 days.', + )} + + ) : ( + + {radar.upcoming.map((licence) => { + const days = daysUntilExpiry(licence, now); + return ( + + + + {holderName(licence)} + + + {localized(licence.licenseType?.name, locale) || + licence.certificateNumber} + + + + {t('logisticsHead.renewals.inDays', { + count: days, + defaultValue: '{{count}}d', + })} + + + ); + })} + + )} + + {partial && ( + + {t( + 'logisticsHead.renewals.partial', + 'The register returned only part of its rows, so these counts cover what was loaded rather than every issued licence.', + )} + + )} + + + {t('logisticsHead.renewals.openRegister', 'Open the licence register')} + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/WorkloadPanels.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/WorkloadPanels.tsx new file mode 100644 index 000000000..0dcedbd0b --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/WorkloadPanels.tsx @@ -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 ( + + + + + +
+ + {title} + + + {subtitle} + +
+
+ {right} +
+ ); +} + +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 ( + + + {t('logisticsHead.officers.count', { + count: loads.filter((load) => load.officerId !== null).length, + defaultValue: '{{count}} officers', + })} + + } + /> + + {loads.length === 0 ? ( + + ) : ( + + + + + {t('logisticsHead.officers.officer', 'Officer')} + + + {t('logisticsHead.officers.load', 'Load')} + {median > 0 && ( + + {t('logisticsHead.officers.medianTick', { + count: median, + defaultValue: '· team median {{count}}', + })} + + )} + + + + {t('logisticsHead.officers.active', 'Open')} + + + {t('logisticsHead.officers.oldest', 'Oldest')} + + + + + {loads.map((load) => ( + onSelect(load.officerId)} + > + + + {load.officerId === null && ( + + + + )} + + {load.name} + + + + + + + {/* The team median, as a tick every row shares. */} + {median > 0 && ( + + )} + {/* + 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. + */} + + {( + [ + [load.onTrack, 'teal'], + [load.atRisk, 'yellow'], + [load.overdue, 'red'], + ] as const + ).map(([count, color]) => + count > 0 ? ( + + ) : null, + )} + + + + + + + + {load.active} + + {load.overdue > 0 && ( + + {load.overdue} + + )} + + + + + {t('logisticsHead.columns.days', { + count: load.oldestDays, + defaultValue: '{{count}}d', + })} + + + + ))} + +
+
+ )} +
+ ); +} + +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 ( + + + + {loads.length === 0 ? ( + + ) : ( + + + + + {t('logisticsHead.types.type', 'Licence type')} + {t('logisticsHead.types.open', 'Open')} + + {t('logisticsHead.types.unassigned', 'Unassigned')} + + {t('logisticsHead.types.late', 'Late')} + + {t('logisticsHead.types.median', 'Median age')} + + {t('logisticsHead.types.target', 'Target')} + + + + {loads.map((load) => ( + onSelect(load.type.key)} + > + + + + {localized(load.type.name, locale) || load.type.key} + + {load.type.inspectionRequired && ( + + {t('logisticsHead.types.inspected', 'Inspection required')} + + )} + + + + + {load.open} + + + + {load.unassigned > 0 ? ( + + {load.unassigned} + + ) : ( + + — + + )} + + + {load.overdue > 0 ? ( + + {load.overdue} + + ) : load.atRisk > 0 ? ( + + {t('logisticsHead.types.atRisk', { + count: load.atRisk, + defaultValue: '{{count}} at risk', + })} + + ) : ( + + — + + )} + + + + {t('logisticsHead.columns.days', { + count: load.medianDays, + defaultValue: '{{count}}d', + })} + + + + + {load.type.slaHours + ? t('logisticsHead.types.targetDays', { + count: Math.round(load.type.slaHours / 24), + defaultValue: '{{count}}d', + }) + : t('logisticsHead.types.noTarget', 'Not tracked')} + + + + ))} + +
+
+ )} +
+ ); +} + +/** 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 = ( + + + + {label} + + + {value} + + + ); + + return onClick ? ( + + {body} + + ) : ( + body + ); +} diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx index 817fbc21b..37b81e331 100644 --- a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx @@ -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[] = - [ +/** A decorated row, given the `id` `AdvancedTable` keys its rows on. */ +export type WorklistRow = DecoratedApplication & { id: string }; + +const KIND_COLOR: Record = { + NEW: 'blue', + RENEWAL: 'teal', + REISSUE: 'orange', +}; + +interface WorklistColumnOptions { + /** Officer id → display name, for the column showing who holds the file. */ + officerNames: Map; + 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[] { + const { officerNames, onOpen, onAssign, assigning, showOfficer = true } = options; + + const columns: AdvancedColumn[] = [ { - header: 'Number', + header: t('queue.number', 'App #'), + label: t('queue.number', 'App #'), cell: ({ row }) => ( - - {row.original.applicationNumber} - + + + {row.original.app.applicationNumber} + + {row.original.app.kind !== 'NEW' && ( + + {t( + `queue.kindValues.${row.original.app.kind}`, + row.original.app.kind === 'RENEWAL' ? 'Renewal' : 'Replacement', + )} + + )} + ), }, { - header: 'Company', - cell: ({ row }) => {row.original.companyName ?? '—'}, + header: t('queue.company', 'Company'), + label: t('queue.company', 'Company'), + cell: ({ row }) => ( + + + {applicantOrCompanyName(row.original.app) ?? '—'} + + + {row.original.app.tinNumber + ? `${t('queue.tin', 'TIN')} ${row.original.app.tinNumber}` + : '—'} + + + ), }, { - 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 ( + + + {name} + + + ); + }, + }, + { + 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 ( + + {label} + + ); + }, + }, + { + header: t('logisticsHead.columns.age', 'Age'), + label: t('logisticsHead.columns.age', 'Age'), cell: ({ row }) => ( - - {STATUS_LABELS[row.original.status as LicenseStatus]} - + + {t('logisticsHead.columns.days', { + count: row.original.ageDays, + defaultValue: '{{count}}d', + })} + +
), }, + { + 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 ( + + + {sla.label} + + + ); + }, + }, ]; + + if (showOfficer) { + columns.push({ + header: t('logisticsHead.columns.officer', 'Officer'), + label: t('logisticsHead.columns.officer', 'Officer'), + cell: ({ row }) => + row.original.officerId ? ( + + {officerNames.get(row.original.officerId) ?? + `#${row.original.officerId.slice(0, 8)}`} + + ) : ( + + {t('logisticsHead.unassigned', 'Unassigned')} + + ), + }); + } + + columns.push({ + header: '', + label: t('queue.actionsColumn', 'Actions'), + align: 'right', + size: 150, + cell: ({ row }) => ( + + {onAssign && row.original.officerId === null && ( + + + + )} + + + ), + }); + + return columns; +} diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx index e4e24b88e..62ea7358b 100644 --- a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx @@ -1,151 +1,988 @@ -import { useNavigate } from 'react-router-dom'; -import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core'; -import { IconChevronRight } from '@tabler/icons-react'; -import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui'; +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { - STATUS_COLORS, - STATUS_LABELS, - useGetAssignedToMeQuery, - useGetQueueQuery, - type LicenseStatus, + ActionIcon, + Alert, + Badge, + Button, + Card, + Grid, + Group, + SegmentedControl, + Select, + SimpleGrid, + Stack, + Tabs, + Text, + ThemeIcon, + Tooltip, +} from '@mantine/core'; +import { + IconAlertTriangle, + IconCash, + IconCertificate, + IconCircleCheck, + IconChevronRight, + IconClockExclamation, + IconDownload, + IconInbox, + IconListCheck, + IconRefresh, + IconUserExclamation, + IconUserPlus, + IconUsers, +} from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { + extractErrorMessage, + localized, + useAssignReviewerMutation, + useGetAllApplicationsQuery, + useGetAssignableOfficersQuery, + useGetLicenseTypesQuery, + useGetLicensesQuery, + type LicenseType, + type QueueFilter, } from '@ema-platform/api'; -import { logisticsHeadDashboardColumns } from './columns'; +import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth'; +import { + AdvancedTable, + ErrorState, + PageHeader, + PageLoader, + StatTile, + notify, + useServerTable, +} from '@ema-platform/ui'; +import { AssignDialog } from '../../../license-review/components/AssignDialog'; +import { + LOGISTICS_OPEN_STATUSES, + ageProfile, + bottleneckStage, + decorate, + intakeCohorts, + intakeWindowStart, + isLogisticsApplication, + recentFlow, + renewalRadar, + statusesInStage, + summariseOfficers, + summarisePipeline, + summariseStages, + summariseTypes, + worklist, + type LogisticsStage, + type WorklistId, +} from '../../logistics-metrics'; +import { downloadLogisticsReport } from '../../report'; +import { PipelineBoard, stageLabel } from './PipelineBoard'; +import { + AgeProfileChart, + IntakeCohortChart, + SlaHealthCard, + ageBucketLabel, +} from './LogisticsCharts'; +import { OfficerWorkloadCard, TypePerformanceCard } from './WorkloadPanels'; +import { RenewalPanel } from './RenewalPanel'; +import { worklistColumns, type WorklistRow } from './columns'; /** - * Logistics department overview. + * How many rows the two queries pull. * - * Every figure is derived from the licence applications actually in the - * system. This previously summed six hardcoded arrays, so the department head - * saw counts for applications that had never been filed. + * Both are asked for `familyKind: LOGISTICS_LICENSE`, so the server returns + * only this department's work rather than the whole authority's — before that + * filter existed the page pulled every non-draft application in the system and + * discarded the other families here, which made these caps bite at a fraction + * of the department's own volume. They are now well clear of it; when one is + * hit anyway the page says so in a banner rather than quietly presenting a + * partial total as the whole truth. + */ +const PIPELINE_TAKE = 500; +const HISTORY_TAKE = 800; + +/** + * How often the page re-reads the queue. + * + * This screen is left open — it is the one a department head keeps on a second + * monitor — and a dispatch decision made against a ten-minute-old queue is a + * decision made against work someone else has already picked up. Paused while + * the tab is in the background so an idle window costs nothing. + */ +const POLL_MS = 120_000; + +/** + * Trailing window the KPI tiles describe movement over. + * + * A week, because that is the unit a department head plans in — and because + * the numbers behind it (what arrived, what went late) are derived from the + * open rows themselves rather than from a stored snapshot, which the API does + * not keep. A true week-on-week delta of the *stock* — "52 open, up 6" — + * would have to be invented, so this page does not show one. + */ +const TREND_DAYS = 7; + +/** Days of unattended intake past which the dispatch banner appears. */ +const STALE_DISPATCH_DAYS = 2; + +const PERIODS = [ + { value: '3m', months: 3 }, + { value: '6m', months: 6 }, + { value: '12m', months: 12 }, +] as const; + +type PeriodValue = (typeof PERIODS)[number]['value']; + +const WORKLIST_TABS: Array<{ + id: WorklistId; + icon: typeof IconInbox; + label: string; + empty: string; + /** Dispatch is the only list where handing the file out is the action. */ + assignable?: boolean; + showOfficer?: boolean; +}> = [ + { + id: 'dispatch', + icon: IconUserPlus, + label: 'Awaiting dispatch', + empty: 'Every filed application has been handed to an officer.', + assignable: true, + showOfficer: false, + }, + { + id: 'sla', + icon: IconClockExclamation, + label: 'SLA priority', + empty: 'Nothing is late or close to it.', + }, + { + id: 'applicant', + icon: IconUserExclamation, + label: 'With applicant', + empty: 'No application is waiting on its applicant.', + }, + { + id: 'issue', + icon: IconCertificate, + label: 'Ready to issue', + empty: 'Nothing is waiting for its certificate.', + }, +]; + +/** + * The logistics department's command centre. + * + * Scoped to the operator-licence family — freight forwarding, shipping agency, + * multimodal transport, joint investment and the waiver services — by + * `familyKind`, the real data-model column, so a licence type configured in + * the backoffice appears here without a code change and a seafarer + * certificate never does. + * + * Every figure is derived from the applications the API actually returns for + * that family. Nothing on this page is read from the authority-wide analytics + * endpoint: those numbers span every department, and a departmental dashboard + * that quietly shows the whole authority's totals is worse than one that shows + * none. The arithmetic itself lives in `logistics-metrics.ts`, where it is + * unit-tested. */ export function LogisticsHeadDashboardPage() { + const { t, i18n } = useTranslation(); + const locale = i18n.language; const navigate = useNavigate(); - const queue = useGetQueueQuery(); - const mine = useGetAssignedToMeQuery(); + const { can } = usePermissions(); + + /** + * Facets live in the query string, not in component state. + * + * Every other queue screen here serialises its filters into the URL for one + * reason: "look at the overdue MTO backlog" should be a link an officer can + * paste into a message, not a state they have to describe in prose. A + * dashboard is the screen most likely to be quoted at somebody, so it has + * the least excuse for being unshareable. + */ + const [searchParams, setSearchParams] = useSearchParams(); + + const period: PeriodValue = + PERIODS.find((entry) => entry.value === searchParams.get('period'))?.value ?? + '6m'; + // Passed through unvalidated: the catalogue has not necessarily loaded on + // first render, and dropping the facet until it does would lose the filter + // out of a shared link. An id that matches nothing simply returns no rows. + const typeId = searchParams.get('type'); + const tab: WorklistId = + WORKLIST_TABS.find((entry) => entry.id === searchParams.get('tab'))?.id ?? + 'dispatch'; + + const [assignTarget, setAssignTarget] = useState(null); + // Left at the shared default: `AdvancedTable`'s page-size picker offers + // 10/20/30/40/50, and a size outside that list renders as a blank select. const table = useServerTable(); - if (queue.isLoading || mine.isLoading) { - return ; + const resetPage = table.setPageIndex; + const setFacets = useCallback( + (next: { period?: PeriodValue; type?: string | null; tab?: WorklistId }) => { + const params = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(next)) { + if (value) params.set(key, value); + else params.delete(key); + } + // Replace rather than push: flipping between tabs should not bury the + // page the officer arrived from under a dozen history entries. + setSearchParams(params, { replace: true }); + resetPage(0); + }, + // `table` itself is a fresh object every render; its setter is not. + [searchParams, setSearchParams, resetPage], + ); + + const months = PERIODS.find((p) => p.value === period)?.months ?? 6; + + const submittedFrom = useMemo( + () => intakeWindowStart(months, Date.now()), + [months], + ); + + const typesQuery = useGetLicenseTypesQuery(); + const officersQuery = useGetAssignableOfficersQuery(); + + const baseFilter: QueueFilter = useMemo( + () => ({ + // The department's own work, decided by the server. `familyKind` is the + // denormalised column stamped at submission, so an application keeps the + // family it was filed under even if its type is later reclassified. + familyKind: 'LOGISTICS_LICENSE', + ...(typeId ? { licenseTypeId: typeId } : {}), + }), + [typeId], + ); + + const polling = { pollingInterval: POLL_MS, skipPollingIfUnfocused: true }; + + const pipelineQuery = useGetAllApplicationsQuery( + { + ...baseFilter, + status: LOGISTICS_OPEN_STATUSES, + sortBy: 'submittedAt', + sortDir: 'ASC', + take: PIPELINE_TAKE, + }, + polling, + ); + + const historyQuery = useGetAllApplicationsQuery({ + ...baseFilter, + submittedFrom, + sortBy: 'submittedAt', + sortDir: 'DESC', + take: HISTORY_TAKE, + }); + + // The register is gated separately from the application queue, so a head + // without it gets the rest of the page instead of a failed request and an + // empty panel. + const canSeeRegister = can([LICENSE_PERMISSIONS.VIEW_LICENSES]); + const licencesQuery = useGetLicensesQuery(undefined, { skip: !canSeeRegister }); + + const [assignReviewer, { isLoading: assigning }] = useAssignReviewerMutation(); + + const logisticsTypes: LicenseType[] = useMemo( + () => + (typesQuery.data?.items ?? []) + .filter((type) => type.familyKind === 'LOGISTICS_LICENSE') + .sort((a, b) => a.sortOrder - b.sortOrder), + [typesQuery.data], + ); + + const typesById = useMemo( + () => new Map(logisticsTypes.map((type) => [type.id, type])), + [logisticsTypes], + ); + + /** + * What the filter offers. + * + * Retired types stay in `logisticsTypes` — an application filed before one + * was switched off still needs its name and its SLA target to resolve — but + * offering one as a filter is offering a facet that can only ever come back + * empty, so the picker keeps the active catalogue plus anything that still + * has work in flight. + */ + const selectableTypes = useMemo(() => { + const withOpenWork = new Set( + (pipelineQuery.data?.items ?? []).map((app) => app.licenseTypeId), + ); + return logisticsTypes.filter( + (type) => type.isActive || withOpenWork.has(type.id), + ); + }, [logisticsTypes, pipelineQuery.data]); + + const officerNames = useMemo( + () => + new Map( + (officersQuery.data ?? []) + .filter((officer) => officer.name) + .map((officer) => [officer.id, officer.name as string]), + ), + [officersQuery.data], + ); + + /** + * One pass over the data, one read of the clock. + * + * Ages, SLA states and every count derive from the same `now`, so a tile and + * the row it counted can never disagree because the clock moved between two + * `Date.now()` calls in different memos. + */ + const analysis = useMemo(() => { + const now = Date.now(); + const openApps = (pipelineQuery.data?.items ?? []).filter(isLogisticsApplication); + const historyApps = (historyQuery.data?.items ?? []).filter(isLogisticsApplication); + const rows = decorate(openApps, typesById, now); + + const stages = summariseStages(rows); + return { + now, + rows, + totals: summarisePipeline(rows), + stages, + bottleneck: bottleneckStage(stages), + buckets: ageProfile(rows), + officers: summariseOfficers( + rows, + officersQuery.data ?? [], + t('logisticsHead.unassigned', 'Unassigned'), + ), + types: summariseTypes(rows, logisticsTypes), + cohorts: intakeCohorts(historyApps, months, now, locale), + renewals: renewalRadar(licencesQuery.data?.items ?? [], now), + flow: recentFlow(rows, TREND_DAYS, now), + }; + }, [ + pipelineQuery.data, + historyQuery.data, + licencesQuery.data, + officersQuery.data, + typesById, + logisticsTypes, + months, + locale, + t, + ]); + + const { totals } = analysis; + + /** Each list built once, so the tab badges, the grid and the report agree. */ + const lists = useMemo( + () => + Object.fromEntries( + WORKLIST_TABS.map((entry) => [ + entry.id, + worklist(entry.id, analysis.rows).map((row) => ({ + ...row, + id: row.app.id, + })), + ]), + ) as Record, + [analysis.rows], + ); + + const paged = table.paginate(lists[tab]); + + const staleDispatch = lists.dispatch.filter( + (row) => row.ageDays >= STALE_DISPATCH_DAYS, + ).length; + + const periodLabel = t(`logisticsHead.periods.${period}`, { + count: months, + defaultValue: 'Last {{count}} months', + }); + const typeLabel = typeId + ? localized(typesById.get(typeId)?.name, locale) || typeId + : t('logisticsHead.allTypes', 'All operator licences'); + + // ------------------------------------------------------------- navigation + + /** Opens the queue with the same filter the clicked figure represents. */ + const openQueue = (params: Record) => { + const search = new URLSearchParams({ view: 'all' }); + if (typeId) search.set('type', typeId); + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value); + } + navigate(`/licence-review?${search.toString()}`); + }; + + const openStage = (stage: LogisticsStage) => + openQueue({ status: statusesInStage(stage).join(',') }); + + const openOfficer = (officerId: string | null) => + openQueue({ assignee: officerId ?? 'unassigned' }); + + // --------------------------------------------------------------- actions + + const refetchAll = () => { + pipelineQuery.refetch(); + historyQuery.refetch(); + if (canSeeRegister) licencesQuery.refetch(); + officersQuery.refetch(); + }; + + const handleExport = () => { + const filename = downloadLogisticsReport({ + generatedAt: analysis.now, + periodLabel, + typeLabel, + locale, + totals, + stages: analysis.stages, + ageProfile: analysis.buckets, + officers: analysis.officers, + types: analysis.types, + cohorts: analysis.cohorts, + renewals: analysis.renewals, + slaCritical: lists.sla, + awaitingDispatch: lists.dispatch, + stageLabel: (stage) => stageLabel(t, stage), + bucketLabel: (bucket) => ageBucketLabel(t, bucket), + }); + notify.success( + t('logisticsHead.exportDone', { + filename, + defaultValue: 'Departmental report saved as {{filename}}.', + }), + ); + }; + + async function handleAssign(officerId: string, remark?: string) { + if (!assignTarget) return; + try { + await assignReviewer({ id: assignTarget.app.id, officerId, remark }).unwrap(); + notify.success( + t('queue.assignedBody', 'The employee has been notified and the review has started.'), + t('queue.assigned', 'Assigned'), + ); + setAssignTarget(null); + } catch (error) { + notify.error( + extractErrorMessage( + error, + t('queue.assignError', 'The application could not be assigned.'), + ), + t('queue.assignFailed', 'Could not assign'), + ); + } finally { + // Whether it succeeded or collided with another leader's assignment, the + // dashboard must show what is true now rather than what it assumed. + pipelineQuery.refetch(); + } } - const unclaimed = queue.data?.items ?? []; - const inProgress = mine.data?.items ?? []; - const all = [...unclaimed, ...inProgress]; + // ----------------------------------------------------------------- states - const byStatus = all.reduce>((acc, app) => { - acc[app.status] = (acc[app.status] ?? 0) + 1; - return acc; - }, {}); + if (typesQuery.isLoading || pipelineQuery.isLoading) { + return ( + + ); + } - const stats = [ - { label: 'Awaiting claim', value: unclaimed.length, color: 'blue' }, - { label: 'In progress', value: inProgress.length, color: 'indigo' }, - { - label: 'Awaiting payment', - value: byStatus['PAYMENT_PENDING'] ?? 0, - color: 'yellow', - }, - { - label: 'Needs applicant action', - value: byStatus['RESUBMIT_REQUIRED'] ?? 0, - color: 'orange', - }, - ]; + if (pipelineQuery.isError) { + return ( + + ); + } - const recent = [...all] - .sort((a, b) => - (b.submittedAt ?? b.createdAt).localeCompare(a.submittedAt ?? a.createdAt), - ) - .slice(0, 8); + const pipelineShown = pipelineQuery.data?.items.length ?? 0; + const pipelineTotal = pipelineQuery.data?.total ?? 0; + const pipelineTruncated = pipelineTotal > pipelineShown; + // The history query is capped too, and it is sorted newest-first — so when + // it truncates, what falls off the end is the oldest months, which is + // exactly the part of the intake chart a reader would trust least if the + // page did not say so. + const historyShown = historyQuery.data?.items.length ?? 0; + const historyTotal = historyQuery.data?.total ?? 0; + const historyTruncated = historyTotal > historyShown; + const busy = pipelineQuery.isFetching || historyQuery.isFetching; + const activeTab = WORKLIST_TABS.find((entry) => entry.id === tab) ?? WORKLIST_TABS[0]; - const paged = table.paginate(recent); + /** + * Nothing open at all. + * + * Worth its own treatment rather than letting the normal layout render: an + * empty department otherwise draws six zeroes, a board of empty stages, two + * blank charts and four empty worklists, which reads as a broken page rather + * than as the good news it is. The backward-looking panels stay, because + * intake history and upcoming renewals are still real with an empty queue — + * and the renewal panel is the one thing that tells a head what is coming. + */ + const pipelineClear = totals.open === 0 && !pipelineQuery.isFetching; return ( - - + + + - - {stats.map((stat) => ( - - - {stat.label} + +