feat: implement interactive vessel registration reporting page with filters, charts, and data tables

This commit is contained in:
estifanos
2026-08-18 08:57:51 +00:00
parent 1dc82d943a
commit 2b95454b4b
16 changed files with 2333 additions and 24 deletions

View File

@@ -5,4 +5,4 @@ export * from './lib/features/licensing';
export * from './lib/features/seafarer';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument } from './lib/base-api/download';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -41,3 +41,57 @@ export async function openAuthedDocument(
// Revoking immediately would race the new tab's load.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
/**
* Downloads an authenticated endpoint straight to a file.
*
* Same reason as `openAuthedDocument` for bypassing RTK Query — `fetchBaseQuery`
* would parse a CSV body as JSON — but a spreadsheet is something you save, not
* something the browser can display, so this always takes the anchor path.
*
* The server names the file via `Content-Disposition`, and the API's CORS
* config exposes that header along with `X-Total-Rows` and `X-Truncated`; those
* two are returned so a caller can say when an export was cut short instead of
* handing over a silently partial file.
*/
export async function downloadAuthedFile(
path: string,
fallbackName: string,
): Promise<{ rowCount: number | null; truncated: boolean }> {
const token = resolveTokenFromStorage();
const response = await fetch(`${BASE_API_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
let message = `${response.status}`;
try {
const body = await response.json();
message = body?.message ?? message;
} catch {
/* non-JSON error body — the status is all we have */
}
throw new Error(message);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filenameFrom(response.headers) ?? fallbackName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
const rows = response.headers.get('X-Total-Rows');
return {
rowCount: rows === null ? null : Number(rows),
truncated: response.headers.get('X-Truncated') === 'true',
};
}
/** `attachment; filename="vessel-register-2026-08-18.csv"` → the file name. */
function filenameFrom(headers: Headers): string | null {
const disposition = headers.get('Content-Disposition');
if (!disposition) return null;
const match = /filename="?([^";]+)"?/.exec(disposition);
return match?.[1] ?? null;
}

View File

@@ -3,6 +3,8 @@ import type {
CreateVesselIncident,
Vessel,
VesselIncident,
VesselReport,
VesselReportQuery,
VesselStatus,
} from './vessel.types';
@@ -35,6 +37,22 @@ export const vesselApi = baseApi
providesTags: () => [listTag('Vessel')],
}),
/**
* The whole backoffice dashboard in one call — KPIs, time series,
* breakdowns and worklists. Backoffice only (`can:View:vessel-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.
*/
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
query: (params) => ({
url: '/vessels/report',
params: params ?? undefined,
}),
providesTags: () => [listTag('Vessel')],
}),
getVessel: builder.query<Vessel, string>({
query: (id) => ({ url: `/vessels/${id}` }),
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
@@ -77,6 +95,7 @@ export const vesselApi = baseApi
export const {
useGetMyVesselsQuery,
useGetVesselsQuery,
useGetVesselReportQuery,
useGetVesselQuery,
useUpdateVesselStatusMutation,
useGetVesselIncidentsQuery,

View File

@@ -49,3 +49,247 @@ export interface CreateVesselIncident {
description: string;
severity?: string;
}
// ---------------------------------------------------------------------------
// Vessel registration report (GET /vessels/report)
//
// One call fills the whole backoffice dashboard. Unlike `Vessel` above, every
// numeric field here is already a real number — the API casts the Postgres
// `numeric` strings before it answers.
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
/**
* One slice of a breakdown chart.
*
* `percentage` is of the whole, not of the slices that survived the `topN`
* cut, so a set of slices always totals 100.
*/
export interface BreakdownItem {
key: string;
label: string;
count: number;
percentage: number;
}
export interface VesselReportQuery {
/** Bounds the time series and the "in period" figures only. */
from?: string;
to?: string;
granularity?: ReportGranularity;
category?: VesselCategory[];
status?: VesselStatus[];
flagState?: string[];
portOfRegistry?: string[];
vesselType?: string[];
search?: string;
expiringWithinDays?: number;
/** Slices kept per high-cardinality chart; the tail collapses into "Other". */
topN?: number;
tableLimit?: number;
}
export interface RegisterKpis {
total: number;
registered: number;
suspended: number;
deregistered: number;
registeredInPeriod: number;
registeredInPreviousPeriod: number;
/** Null when there is no previous period to compare against. */
changePct: number | null;
}
export interface FleetKpis {
totalGrossTonnage: number;
avgGrossTonnage: number | null;
/** How many hulls the tonnage average actually covers. */
grossTonnageKnownFor: number;
totalPassengerCapacity: number;
avgLengthMeters: number | null;
avgAgeYears: number | null;
ageKnownFor: number;
seaGoing: number;
inlandWaterway: number;
}
export interface PipelineKpis {
total: number;
draft: number;
inProgress: number;
approved: number;
rejected: number;
issued: number;
submittedInPeriod: number;
decidedInPeriod: number;
newCount: number;
renewalCount: number;
/** Approved over settled. Null while nothing has been decided. */
approvalRatePct: number | null;
avgProcessingDays: number | null;
medianProcessingDays: number | null;
avgAdjustmentRounds: number | null;
}
export interface CertificateKpis {
total: number;
active: number;
expired: number;
suspended: number;
/** Cumulative: a certificate due in 11 days is inside all three. */
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
missingCertificate: number;
}
export interface IncidentKpis {
total: number;
inPeriod: number;
reportedByOfficer: number;
reportedByOwner: number;
vesselsWithIncidents: number;
}
export interface RevenueKpis {
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;
}
/** Bucketed series. `bucket` is an ISO date; the window is zero-filled. */
export interface RegistrationBucket {
bucket: string;
count: number;
grossTonnage: number;
}
export interface ApplicationBucket {
bucket: string;
submitted: number;
approved: number;
rejected: number;
issued: number;
}
export interface IncidentBucket {
bucket: string;
count: number;
}
export interface RevenueBucket {
bucket: string;
amount: number;
count: number;
}
export interface ExpiringCertificateRow {
vesselId: string;
registrationNumber: string;
name: string;
ownerName: string | null;
ownerUserId: string;
certificateNumber: string | null;
expiryDate: string;
certificateStatus: string | null;
/** 0 means it expires today, which still counts as live. */
daysToExpiry: number;
}
export interface RecentRegistrationRow {
vesselId: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
flagState: string | null;
grossTonnage: number | null;
ownerName: string | null;
status: VesselStatus;
registeredAt: string;
}
export interface RecentIncidentRow {
id: string;
vesselId: string;
registrationNumber: string;
vesselName: string;
occurredAt: string;
severity: string | null;
location: string | null;
description: string;
reportedByOfficer: boolean;
}
export interface PendingApplicationRow {
applicationNumber: string;
status: string;
kind: 'NEW' | 'RENEWAL';
assignedOfficerId: string | null;
submittedAt: string | null;
adjustmentRound: number;
daysOpen: number;
}
export interface VesselReport {
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;
category: VesselCategory[] | null;
status: VesselStatus[] | null;
flagState: string[] | null;
portOfRegistry: string[] | null;
vesselType: string[] | null;
search: string | null;
};
kpis: {
register: RegisterKpis;
fleet: FleetKpis;
pipeline: PipelineKpis;
certificates: CertificateKpis;
incidents: IncidentKpis;
revenue: RevenueKpis;
};
timeSeries: {
registrations: RegistrationBucket[];
applications: ApplicationBucket[];
incidents: IncidentBucket[];
revenue: RevenueBucket[];
};
breakdowns: {
byStatus: BreakdownItem[];
byCategory: BreakdownItem[];
byFlagState: BreakdownItem[];
byPortOfRegistry: BreakdownItem[];
byVesselType: BreakdownItem[];
byHullMaterial: BreakdownItem[];
byEngineType: BreakdownItem[];
byTonnageBand: BreakdownItem[];
byLengthBand: BreakdownItem[];
byAgeBand: BreakdownItem[];
byBuildDecade: BreakdownItem[];
byApplicationStatus: BreakdownItem[];
byApplicationKind: BreakdownItem[];
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
byOfficer: BreakdownItem[];
byIncidentSeverity: BreakdownItem[];
};
tables: {
expiringCertificates: ExpiringCertificateRow[];
recentRegistrations: RecentRegistrationRow[];
recentIncidents: RecentIncidentRow[];
pendingApplications: PendingApplicationRow[];
};
}