mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement interactive vessel registration reporting page with filters, charts, and data tables
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user