mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 05:58:19 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type {
|
||||
AdminDashboardAnalytics,
|
||||
AdminDashboardAnalyticsArgs,
|
||||
ApplicantDashboardSummary,
|
||||
ExamStateView,
|
||||
AppNotification,
|
||||
@@ -821,11 +822,14 @@ export const licensingApi = baseApi
|
||||
|
||||
getAdminDashboardAnalytics: builder.query<
|
||||
AdminDashboardAnalytics,
|
||||
{ period?: string } | void
|
||||
AdminDashboardAnalyticsArgs | void
|
||||
>({
|
||||
query: (args) => ({
|
||||
url: '/license-application-review/dashboard-analytics',
|
||||
params: args?.period ? { period: args.period } : undefined,
|
||||
params: dropEmpty({
|
||||
period: args?.period,
|
||||
familyKind: args?.familyKind,
|
||||
}),
|
||||
}),
|
||||
providesTags: () => [listTag('ApplicationQueue'), listTag('License')],
|
||||
}),
|
||||
|
||||
@@ -652,6 +652,12 @@ export interface AppNotification {
|
||||
/** Server-side queue filters. Mirrors ApplicationQueueFilterDto in the API. */
|
||||
export interface QueueFilter {
|
||||
licenseTypeId?: string;
|
||||
/**
|
||||
* Scope to one business family. A departmental screen owns exactly one, and
|
||||
* asking the server for it beats pulling every family and discarding the
|
||||
* rest — which made a page's row cap bite long before its own volume did.
|
||||
*/
|
||||
familyKind?: FamilyKind;
|
||||
search?: string;
|
||||
status?: LicenseStatus[];
|
||||
kind?: ApplicationKind;
|
||||
@@ -1041,6 +1047,14 @@ export interface ApplicantDashboardSummary {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Query for {@link AdminDashboardAnalytics}. */
|
||||
export interface AdminDashboardAnalyticsArgs {
|
||||
/** "7d" | "30d" | "6m" (default) | "1y". */
|
||||
period?: string;
|
||||
/** Scopes every figure to one department's family. Omit for the authority. */
|
||||
familyKind?: FamilyKind;
|
||||
}
|
||||
|
||||
export interface AdminDashboardAnalytics {
|
||||
kpis: {
|
||||
totalApplications: number;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './seafarer.types';
|
||||
export * from './seafarer-report.types';
|
||||
export * from './seafarer-api';
|
||||
export * from './seafarer.helpers';
|
||||
|
||||
@@ -8,8 +8,21 @@ import type {
|
||||
SeaTimeSummary,
|
||||
SeafarerStatus,
|
||||
} from './seafarer.types';
|
||||
import type { SeafarerReport, SeafarerReportQuery } from './seafarer-report.types';
|
||||
|
||||
const TAGS = ['SeaServiceRecord', 'MedicalCertificate'] as const;
|
||||
/**
|
||||
* The report reads across every shelf a seafarer has, so it is tagged with all
|
||||
* of them: approving a registration, issuing a seaman book or verifying a
|
||||
* medical certificate all move figures on the dashboard, and a stale KPI beside
|
||||
* a fresh queue is worse than no KPI.
|
||||
*/
|
||||
const TAGS = [
|
||||
'SeaServiceRecord',
|
||||
'MedicalCertificate',
|
||||
'SeafarerRegistration',
|
||||
'SeafarerDocument',
|
||||
'License',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
|
||||
@@ -22,6 +35,31 @@ export const seafarerApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: TAGS })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// --------------------------------------------------------------- report
|
||||
|
||||
/**
|
||||
* The whole seafarer services dashboard in one call — KPIs, time series,
|
||||
* breakdowns and worklists. Backoffice only
|
||||
* (`can:View:seafarer-registry`).
|
||||
*
|
||||
* Array filters are passed as arrays, not joined strings: the API accepts
|
||||
* both the repeated and the comma-separated form, and `params`
|
||||
* serialises the repeated one.
|
||||
*/
|
||||
getSeafarerReport: builder.query<SeafarerReport, SeafarerReportQuery | void>({
|
||||
query: (params) => ({
|
||||
url: '/seafarer-registry/report',
|
||||
params: params ?? undefined,
|
||||
}),
|
||||
providesTags: () => [
|
||||
listTag('SeafarerRegistration'),
|
||||
listTag('SeafarerDocument'),
|
||||
listTag('SeaServiceRecord'),
|
||||
listTag('MedicalCertificate'),
|
||||
listTag('License'),
|
||||
],
|
||||
}),
|
||||
|
||||
// ---------------------------------------------------------- sea service
|
||||
getMySeaServiceRecords: builder.query<SeaServiceRecord[], void>({
|
||||
query: () => ({ url: '/sea-service-records/mine' }),
|
||||
@@ -181,6 +219,7 @@ export const seafarerApi = baseApi
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetSeafarerReportQuery,
|
||||
useGetPendingSeaServiceQuery,
|
||||
useGetPendingMedicalQuery,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
|
||||
377
libs/api/src/lib/features/seafarer/seafarer-report.types.ts
Normal file
377
libs/api/src/lib/features/seafarer/seafarer-report.types.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import type { BreakdownItem, ReportGranularity } from '../vessel/vessel.types';
|
||||
import type {
|
||||
Gender,
|
||||
RankTier,
|
||||
SeafarerRegistrationStatus,
|
||||
} from '../seafarer-registration/seafarer-registration.types';
|
||||
import type {
|
||||
SeafarerDocumentKind,
|
||||
SeafarerDocumentStatus,
|
||||
} from '../seafarer-document/seafarer-document.types';
|
||||
import type { MedicalFitness, SeafarerRecordStatus } from './seafarer.types';
|
||||
|
||||
// ------------------------------------------------- seafarer services report
|
||||
//
|
||||
// One call fills the whole seafarer services dashboard. Every numeric field
|
||||
// here is already a real number — the API casts the Postgres `numeric` strings
|
||||
// before it answers.
|
||||
//
|
||||
// `BreakdownItem` and `ReportGranularity` are shared with the vessel report
|
||||
// rather than redeclared: the two dashboards render the same chart components
|
||||
// against the same server-side helpers, and two structurally identical types
|
||||
// would drift the first time one of them gained a field.
|
||||
|
||||
/** The licence types that are seafarer certificates. */
|
||||
export type SeafarerCertificateTypeKey =
|
||||
| 'CERTIFICATE_OF_COMPETENCY'
|
||||
| 'CERTIFICATE_OF_PROFICIENCY'
|
||||
| 'ENDORSEMENT_SEAFARER'
|
||||
| 'ENDORSEMENT_COC'
|
||||
| 'ENDORSEMENT_GOC';
|
||||
|
||||
export interface SeafarerReportQuery {
|
||||
/** Bounds the time series and the "in period" figures only. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
granularity?: ReportGranularity;
|
||||
status?: SeafarerRegistrationStatus[];
|
||||
department?: string[];
|
||||
tier?: RankTier[];
|
||||
gender?: Gender[];
|
||||
nationality?: string[];
|
||||
search?: string;
|
||||
expiringWithinDays?: number;
|
||||
/** Slices kept per high-cardinality chart; the tail collapses into "Other". */
|
||||
topN?: number;
|
||||
tableLimit?: number;
|
||||
}
|
||||
|
||||
export interface RegistryKpis {
|
||||
total: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
draft: number;
|
||||
awaitingBiometrics: number;
|
||||
underReview: number;
|
||||
submitted: number;
|
||||
resubmitRequired: number;
|
||||
/** Filed and still moving: the four non-draft, non-decided statuses. */
|
||||
pending: number;
|
||||
/** Approvals dated by their decision, not their submission. */
|
||||
registeredInPeriod: number;
|
||||
registeredInPreviousPeriod: number;
|
||||
/** Null when the previous period was empty — no basis to compare. */
|
||||
changePct: number | null;
|
||||
}
|
||||
|
||||
export interface RegistrationPipelineKpis {
|
||||
submittedInPeriod: number;
|
||||
decidedInPeriod: number;
|
||||
/** Approved over settled. Null while nothing has been decided. */
|
||||
approvalRatePct: number | null;
|
||||
avgProcessingDays: number | null;
|
||||
medianProcessingDays: number | null;
|
||||
backlog: number;
|
||||
oldestPendingDays: number | null;
|
||||
avgPendingDays: number | null;
|
||||
/** The one backlog a reviewer cannot clear alone — it needs the desk. */
|
||||
awaitingBiometrics: number;
|
||||
}
|
||||
|
||||
export interface DemographicsKpis {
|
||||
avgAgeYears: number | null;
|
||||
/** How many records the age average actually covers. */
|
||||
ageKnownFor: number;
|
||||
male: number;
|
||||
female: number;
|
||||
genderKnownFor: number;
|
||||
femalePct: number | null;
|
||||
nationalities: number;
|
||||
departments: number;
|
||||
}
|
||||
|
||||
export interface SeafarerDocumentKpis {
|
||||
total: number;
|
||||
issued: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
cancelled: number;
|
||||
awaitingPayment: number;
|
||||
scheduled: number;
|
||||
seamanBookIssued: number;
|
||||
seamanBookPending: number;
|
||||
btcIssued: number;
|
||||
btcPending: number;
|
||||
issuedInPeriod: number;
|
||||
expired: number;
|
||||
/** Cumulative: a book due in 11 days is inside all three. */
|
||||
expiringIn30: number;
|
||||
expiringIn60: number;
|
||||
expiringIn90: number;
|
||||
avgIssuanceDays: number | null;
|
||||
medianIssuanceDays: number | null;
|
||||
}
|
||||
|
||||
export interface SeafarerCertificateKpis {
|
||||
total: number;
|
||||
active: number;
|
||||
expired: number;
|
||||
suspended: number;
|
||||
cancelled: number;
|
||||
cocActive: number;
|
||||
copActive: number;
|
||||
/** The three endorsement keys summed — one product to a reader. */
|
||||
endorsementActive: number;
|
||||
expiringIn30: number;
|
||||
expiringIn60: number;
|
||||
expiringIn90: number;
|
||||
/** Distinct holders of a live certificate. */
|
||||
holders: number;
|
||||
applicationsInProgress: number;
|
||||
applicationsApprovalRatePct: number | null;
|
||||
applicationsMedianProcessingDays: number | null;
|
||||
}
|
||||
|
||||
export interface MedicalKpis {
|
||||
total: number;
|
||||
valid: number;
|
||||
expired: number;
|
||||
expiringIn30: number;
|
||||
expiringIn60: number;
|
||||
expiringIn90: number;
|
||||
fit: number;
|
||||
fitWithRestrictions: number;
|
||||
unfit: number;
|
||||
verified: number;
|
||||
pendingVerification: number;
|
||||
rejected: number;
|
||||
/** Seafarers holding at least one live, non-rejected medical. */
|
||||
seafarersCovered: number;
|
||||
/** Seafarers on the shelf with no live medical at all — the chase list. */
|
||||
seafarersLapsed: number;
|
||||
}
|
||||
|
||||
export interface SeaServiceKpis {
|
||||
records: number;
|
||||
verified: number;
|
||||
pendingVerification: number;
|
||||
rejected: number;
|
||||
/** Verified days only — unverified claims are not recognised sea time. */
|
||||
totalSeaDays: number;
|
||||
avgSeaDaysPerSeafarer: number | null;
|
||||
medianSeaDaysPerSeafarer: number | null;
|
||||
seafarersWithService: number;
|
||||
/** The STCW headline threshold: who is a year of sea time in. */
|
||||
seafarersOverTwelveMonths: number;
|
||||
/** Past the threshold with no live CoC — the intake nobody has filed yet. */
|
||||
eligibleUncertified: number;
|
||||
vesselsServed: number;
|
||||
}
|
||||
|
||||
export interface SeafarerRevenueKpis {
|
||||
currency: string;
|
||||
/** True when more than one currency was summed — warn rather than total. */
|
||||
mixedCurrency: boolean;
|
||||
paid: number;
|
||||
pending: number;
|
||||
paidCount: number;
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
refundedCount: number;
|
||||
}
|
||||
|
||||
/** Bucketed series. `bucket` is an ISO date; the window is zero-filled. */
|
||||
export interface RegistrationTrendBucket {
|
||||
bucket: string;
|
||||
submitted: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface DocumentTrendBucket {
|
||||
bucket: string;
|
||||
seamanBook: number;
|
||||
btc: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CertificateTrendBucket {
|
||||
bucket: string;
|
||||
coc: number;
|
||||
cop: number;
|
||||
endorsement: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface VerificationTrendBucket {
|
||||
bucket: string;
|
||||
medical: number;
|
||||
seaService: number;
|
||||
}
|
||||
|
||||
export interface SeafarerRevenueBucket {
|
||||
bucket: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PendingRegistrationRow {
|
||||
id: string;
|
||||
registrationNumber: string;
|
||||
applicantName: string | null;
|
||||
department: string | null;
|
||||
tier: RankTier | null;
|
||||
status: SeafarerRegistrationStatus;
|
||||
submittedAt: string | null;
|
||||
daysOpen: number;
|
||||
}
|
||||
|
||||
export interface RecentSeafarerRegistrationRow {
|
||||
id: string;
|
||||
registrationNumber: string;
|
||||
seafarerNumber: string | null;
|
||||
applicantName: string | null;
|
||||
department: string | null;
|
||||
tier: RankTier | null;
|
||||
nationality: string | null;
|
||||
decidedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ExpiringDocumentRow {
|
||||
id: string;
|
||||
kind: SeafarerDocumentKind;
|
||||
documentNumber: string | null;
|
||||
requestNumber: string;
|
||||
registrationNumber: string | null;
|
||||
holderName: string | null;
|
||||
expiryDate: string;
|
||||
/** 0 means it expires today, which still counts as live. */
|
||||
daysToExpiry: number;
|
||||
}
|
||||
|
||||
export interface ExpiringSeafarerCertificateRow {
|
||||
id: string;
|
||||
certificateNumber: string;
|
||||
typeKey: SeafarerCertificateTypeKey | string;
|
||||
scopeKey: string | null;
|
||||
rank: string | null;
|
||||
holderUserId: string;
|
||||
holderName: string | null;
|
||||
registrationNumber: string | null;
|
||||
expiryDate: string;
|
||||
daysToExpiry: number;
|
||||
}
|
||||
|
||||
export interface ExpiringMedicalRow {
|
||||
id: string;
|
||||
profileId: string;
|
||||
certificateNumber: string | null;
|
||||
issuerName: string;
|
||||
fitnessStatus: MedicalFitness;
|
||||
status: SeafarerRecordStatus;
|
||||
holderName: string | null;
|
||||
registrationNumber: string | null;
|
||||
expiryDate: string;
|
||||
daysToExpiry: number;
|
||||
}
|
||||
|
||||
/** A registered seafarer past the CoC sea-time threshold with no live CoC. */
|
||||
export interface EligibleUncertifiedRow {
|
||||
id: string;
|
||||
registrationNumber: string;
|
||||
seafarerNumber: string | null;
|
||||
applicantName: string | null;
|
||||
department: string | null;
|
||||
tier: RankTier | null;
|
||||
/** Verified sea days. */
|
||||
seaDays: number;
|
||||
holdsCop: boolean;
|
||||
lastDischargeDate: string | null;
|
||||
}
|
||||
|
||||
export interface PendingVerificationRow {
|
||||
id: string;
|
||||
recordType: 'MEDICAL' | 'SEA_SERVICE';
|
||||
holderName: string | null;
|
||||
registrationNumber: string | null;
|
||||
/** Issuer + number, or vessel + rank — enough to recognise the record. */
|
||||
summary: string;
|
||||
submittedAt: string;
|
||||
daysWaiting: number;
|
||||
}
|
||||
|
||||
export interface SeafarerReport {
|
||||
generatedAt: string;
|
||||
/** True when the register passed the API's scan cap — figures are partial. */
|
||||
truncated: boolean;
|
||||
filters: {
|
||||
from: string;
|
||||
to: string;
|
||||
granularity: ReportGranularity;
|
||||
expiringWithinDays: number;
|
||||
topN: number;
|
||||
tableLimit: number;
|
||||
status: SeafarerRegistrationStatus[] | null;
|
||||
department: string[] | null;
|
||||
tier: RankTier[] | null;
|
||||
gender: Gender[] | null;
|
||||
nationality: string[] | null;
|
||||
search: string | null;
|
||||
};
|
||||
kpis: {
|
||||
registry: RegistryKpis;
|
||||
pipeline: RegistrationPipelineKpis;
|
||||
demographics: DemographicsKpis;
|
||||
documents: SeafarerDocumentKpis;
|
||||
certificates: SeafarerCertificateKpis;
|
||||
medical: MedicalKpis;
|
||||
seaService: SeaServiceKpis;
|
||||
revenue: SeafarerRevenueKpis;
|
||||
};
|
||||
timeSeries: {
|
||||
registrations: RegistrationTrendBucket[];
|
||||
documents: DocumentTrendBucket[];
|
||||
certificates: CertificateTrendBucket[];
|
||||
verifications: VerificationTrendBucket[];
|
||||
revenue: SeafarerRevenueBucket[];
|
||||
};
|
||||
breakdowns: {
|
||||
byRegistrationStatus: BreakdownItem[];
|
||||
byDepartment: BreakdownItem[];
|
||||
byTier: BreakdownItem[];
|
||||
byGender: BreakdownItem[];
|
||||
byAgeBand: BreakdownItem[];
|
||||
byNationality: BreakdownItem[];
|
||||
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
|
||||
byReviewOfficer: BreakdownItem[];
|
||||
/** Open registrations by how long they have waited. */
|
||||
byPendingAge: BreakdownItem[];
|
||||
byDocumentKind: BreakdownItem[];
|
||||
byDocumentStatus: BreakdownItem[];
|
||||
byCertificateType: BreakdownItem[];
|
||||
byCertificateStatus: BreakdownItem[];
|
||||
byCertificateRank: BreakdownItem[];
|
||||
byCertApplicationStatus: BreakdownItem[];
|
||||
byMedicalFitness: BreakdownItem[];
|
||||
byMedicalStatus: BreakdownItem[];
|
||||
byMedicalIssuer: BreakdownItem[];
|
||||
bySeaServiceStatus: BreakdownItem[];
|
||||
bySeaServiceRank: BreakdownItem[];
|
||||
bySeaServiceVesselType: BreakdownItem[];
|
||||
bySeaServiceFlagState: BreakdownItem[];
|
||||
/** Banded over the whole population, so zero sea time is visible. */
|
||||
bySeaDaysBand: BreakdownItem[];
|
||||
};
|
||||
tables: {
|
||||
pendingRegistrations: PendingRegistrationRow[];
|
||||
recentRegistrations: RecentSeafarerRegistrationRow[];
|
||||
expiringDocuments: ExpiringDocumentRow[];
|
||||
expiringCertificates: ExpiringSeafarerCertificateRow[];
|
||||
expiringMedicals: ExpiringMedicalRow[];
|
||||
pendingVerifications: PendingVerificationRow[];
|
||||
eligibleUncertified: EligibleUncertifiedRow[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Document status kept for the filter/legend labels the dashboard renders. */
|
||||
export type { SeafarerDocumentStatus };
|
||||
@@ -31,6 +31,7 @@ export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/data/WaitingFor";
|
||||
export * from "./lib/data/StatTile";
|
||||
export * from "./lib/data/report-format";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
export * from "./lib/data/useServerTable";
|
||||
export * from "./lib/landing/LandingPage";
|
||||
|
||||
@@ -53,6 +53,16 @@ interface AdvancedTableProps<T> {
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* Width below which the grid scrolls horizontally instead of compressing.
|
||||
*
|
||||
* The 480 default suits the three- and four-column grids most screens show.
|
||||
* A wider table needs a wider floor: squeezed past it, Mantine ellipsises
|
||||
* badge content, so a status column collapses to "SU…" — unreadable, and
|
||||
* unlike a scrollbar it gives no hint that anything was hidden. Pass the
|
||||
* table's real minimum when it carries more than about five columns.
|
||||
*/
|
||||
minWidth?: number;
|
||||
/** Search box, filters, export — rendered top-right before Refresh/View. */
|
||||
toolbar?: ReactNode;
|
||||
}
|
||||
@@ -88,6 +98,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
||||
onRowClick,
|
||||
title,
|
||||
toolbar,
|
||||
minWidth = 480,
|
||||
}: AdvancedTableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState<boolean[]>(
|
||||
@@ -176,7 +187,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={480}>
|
||||
<Table.ScrollContainer minWidth={minWidth}>
|
||||
<Table verticalSpacing={verticalSpacing}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
|
||||
285
libs/ui/src/lib/data/report-format.ts
Normal file
285
libs/ui/src/lib/data/report-format.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Presentation rules shared by the authority's dashboards (the vessel
|
||||
* register report and the seafarer services report).
|
||||
*
|
||||
* Kept here rather than in either feature folder because the two screens are
|
||||
* meant to read as one product: the same em dash for "no answer", the same
|
||||
* palette assigned in the same order, the same muted treatment for the
|
||||
* bookkeeping slices. Two copies would drift the first time one of them gained
|
||||
* a colour.
|
||||
*
|
||||
* Nothing in this file knows what a vessel or a seafarer is — the
|
||||
* domain-specific labels stay in each feature's own `report-format.ts`, which
|
||||
* re-exports this module so a component has one import.
|
||||
*/
|
||||
|
||||
import { ethMonthName, toEthDateTime } from '@ema-platform/shared';
|
||||
|
||||
/** One slice of a breakdown chart, as every report's API returns it. */
|
||||
export interface ReportBreakdownItem {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/** Nothing measurable is not zero — an em dash says so without lying. */
|
||||
export const DASH = '—';
|
||||
|
||||
/**
|
||||
* A figure the API may legitimately have no answer for.
|
||||
*
|
||||
* An average is null on an empty register and a rate is null until something
|
||||
* has been decided; rendering either as 0 would report a fleet that weighs
|
||||
* nothing and a service that approves nobody.
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number | null | undefined,
|
||||
options: { decimals?: number; suffix?: string } = {},
|
||||
): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
||||
const text = value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: options.decimals ?? 0,
|
||||
maximumFractionDigits: options.decimals ?? 0,
|
||||
});
|
||||
return options.suffix ? `${text}${options.suffix}` : text;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null | undefined): string {
|
||||
return value === null || value === undefined
|
||||
? DASH
|
||||
: `${formatNumber(value, { decimals: 1 })}%`;
|
||||
}
|
||||
|
||||
export function formatMoney(value: number, currency: string): string {
|
||||
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
/** A signed delta for the change-vs-previous chip. */
|
||||
export function formatDelta(value: number | null): string {
|
||||
if (value === null) return DASH;
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
|
||||
}
|
||||
|
||||
export function deltaColor(value: number | null): string {
|
||||
if (value === null || value === 0) return 'gray';
|
||||
return value > 0 ? 'teal' : 'red';
|
||||
}
|
||||
|
||||
/**
|
||||
* Days as a readable duration.
|
||||
*
|
||||
* Sea time and processing times are both days on the wire, but "487 d" is not
|
||||
* a figure anyone reads as "a year and four months", which is the unit
|
||||
* certificate eligibility is argued in.
|
||||
*/
|
||||
export function formatDays(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
||||
if (value < 31) return `${formatNumber(value)} d`;
|
||||
if (value < 365) return `${formatNumber(value / 30.44, { decimals: 1 })} mo`;
|
||||
return `${formatNumber(value / 365.25, { decimals: 1 })} yr`;
|
||||
}
|
||||
|
||||
/** Cumulative expiry counts, as the disjoint bands a chart can stack. */
|
||||
export interface CumulativeExpiry {
|
||||
expiringIn30: number;
|
||||
expiringIn60: number;
|
||||
expiringIn90: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An API's expiry counts are cumulative — a certificate due in eleven days is
|
||||
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
|
||||
* them. Stacked side by side in a chart that reads as three separate groups,
|
||||
* so they are differenced into disjoint bands first.
|
||||
*/
|
||||
export function expiryBands(
|
||||
counts: CumulativeExpiry,
|
||||
): Array<{ label: string; count: number }> {
|
||||
const { expiringIn30, expiringIn60, expiringIn90 } = counts;
|
||||
return [
|
||||
{ label: 'Within 30 days', count: expiringIn30 },
|
||||
// Math.max guards against a server that ever answers non-monotonically —
|
||||
// a negative bar is worse than a zero one.
|
||||
{ label: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||
{ label: '61–90 days', count: Math.max(0, expiringIn90 - expiringIn60) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Red inside a week, orange inside a month, otherwise unremarkable. */
|
||||
export function expiryUrgency(daysToExpiry: number): string {
|
||||
if (daysToExpiry <= 7) return 'red';
|
||||
if (daysToExpiry <= 30) return 'orange';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Officer ids are IAM uuids, which make useless axis labels. Until the
|
||||
* dashboards have a name lookup, shorten them and keep "UNASSIGNED" readable.
|
||||
*/
|
||||
export function officerLabel(key: string): string {
|
||||
if (key === 'UNASSIGNED') return 'Unassigned';
|
||||
return key.length > 8 ? `${key.slice(0, 8)}…` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart colours, assigned by position so a slice keeps its colour between
|
||||
* renders. Mantine's palette rather than invented hex codes, so the charts
|
||||
* follow the theme the rest of the app is built on.
|
||||
*/
|
||||
const PALETTE = [
|
||||
'var(--mantine-color-blue-6)',
|
||||
'var(--mantine-color-teal-6)',
|
||||
'var(--mantine-color-orange-6)',
|
||||
'var(--mantine-color-grape-6)',
|
||||
'var(--mantine-color-cyan-6)',
|
||||
'var(--mantine-color-lime-7)',
|
||||
'var(--mantine-color-pink-6)',
|
||||
'var(--mantine-color-indigo-6)',
|
||||
];
|
||||
|
||||
const MUTED = 'var(--mantine-color-gray-5)';
|
||||
|
||||
/**
|
||||
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
|
||||
* always take the muted colour instead of competing with the real categories
|
||||
* for one of the bright ones.
|
||||
*/
|
||||
export function sliceColor(item: ReportBreakdownItem, index: number): string {
|
||||
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
|
||||
return PALETTE[index % PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket keys are ISO dates; the axis wants something a human reads.
|
||||
*
|
||||
* Under Amharic the tick is the Ethiopian month (and day), the way every other
|
||||
* date in the app already renders through `dateDisplayer` — a chart whose axis
|
||||
* says "Sep 2026" beside a table that says "መስከረም 2019" is two calendars on one
|
||||
* screen. Bucket keys are UTC midnights, so the day is embedded at UTC noon
|
||||
* first: `toEthDateTime` reads local Y/M/D, and in a positive-offset timezone
|
||||
* a UTC midnight is still the previous local day.
|
||||
*/
|
||||
export function formatBucket(
|
||||
bucket: string,
|
||||
granularity: 'DAY' | 'WEEK' | 'MONTH',
|
||||
language = 'en',
|
||||
): string {
|
||||
const date = new Date(bucket);
|
||||
if (Number.isNaN(date.getTime())) return bucket;
|
||||
if (language.startsWith('am')) {
|
||||
const local = new Date(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate(),
|
||||
12,
|
||||
);
|
||||
const eth = toEthDateTime(local);
|
||||
const month = ethMonthName(local);
|
||||
return granularity === 'MONTH'
|
||||
? `${month} ${eth.year}`
|
||||
: `${month} ${eth.date}`;
|
||||
}
|
||||
if (granularity === 'MONTH') {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/** The default window the APIs apply when none is given: the last 12 months. */
|
||||
export function defaultRange(now: Date): [Date, Date] {
|
||||
const from = new Date(
|
||||
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
return [from, now];
|
||||
}
|
||||
|
||||
export const ISO_DAY_LENGTH = 10;
|
||||
|
||||
export const toIsoDay = (date: Date): string =>
|
||||
date.toISOString().slice(0, ISO_DAY_LENGTH);
|
||||
|
||||
/**
|
||||
* The filter state as URL search params, so a filtered dashboard is a
|
||||
* shareable link rather than something the next person has to rebuild.
|
||||
*
|
||||
* Empty arrays and blank strings are dropped rather than serialised, which
|
||||
* keeps an untouched dashboard's URL clean and lets the API apply its own
|
||||
* defaults instead of being handed an empty filter to honour.
|
||||
*/
|
||||
export function queryToSearchParams(query: object): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue;
|
||||
params.set(key, value.join(','));
|
||||
} else {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse, for restoring state from a shared link.
|
||||
*
|
||||
* The key lists are the caller's, because they are the report's filter
|
||||
* contract: a key this report does not understand is dropped rather than
|
||||
* forwarded to fail the API's validation pipe.
|
||||
*/
|
||||
export function searchParamsToQuery<T>(
|
||||
params: URLSearchParams,
|
||||
keys: {
|
||||
arrays: readonly string[];
|
||||
numbers: readonly string[];
|
||||
strings: readonly string[];
|
||||
},
|
||||
): T {
|
||||
const query: Record<string, unknown> = {};
|
||||
for (const key of keys.arrays) {
|
||||
const raw = params.get(key);
|
||||
if (raw) query[key] = raw.split(',').filter(Boolean);
|
||||
}
|
||||
for (const key of keys.numbers) {
|
||||
const raw = params.get(key);
|
||||
// An unparseable number in a hand-edited URL is ignored rather than sent
|
||||
// on to fail the API's validation pipe.
|
||||
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
|
||||
query[key] = Number(raw);
|
||||
}
|
||||
}
|
||||
for (const key of keys.strings) {
|
||||
const raw = params.get(key);
|
||||
if (raw) query[key] = raw;
|
||||
}
|
||||
const granularity = params.get('granularity');
|
||||
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
|
||||
query.granularity = granularity;
|
||||
}
|
||||
return query as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The multi-select options a filter offers, taken from the breakdown the last
|
||||
* response carried — there is no lookup endpoint for flag states, ports or
|
||||
* nationalities, and the register is the only place that knows which ones are
|
||||
* in use.
|
||||
*
|
||||
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
|
||||
* filter the register down to.
|
||||
*/
|
||||
export function optionsFrom(items: ReportBreakdownItem[] | undefined): string[] {
|
||||
return (items ?? [])
|
||||
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
|
||||
.map((item) => item.key);
|
||||
}
|
||||
Reference in New Issue
Block a user