mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -7,4 +7,4 @@ export * from './lib/features/seafarer';
|
||||
export * from './lib/features/seafarer-registration';
|
||||
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,8 +41,13 @@ export const baseQueryWithReauth: BaseQueryFn<
|
||||
try {
|
||||
await _onTokenExpired();
|
||||
result = await baseQuery(args, api, extraOptions);
|
||||
} catch {
|
||||
_onAuthFailure?.();
|
||||
} catch (err) {
|
||||
// Only a rejected refresh token ends the session. A network blip or a
|
||||
// 5xx leaves the original 401 for the screen to report, rather than
|
||||
// throwing the user out of a session that is still valid.
|
||||
if ((err as { sessionExpired?: boolean })?.sessionExpired) {
|
||||
_onAuthFailure?.();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_onAuthFailure?.();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -398,8 +398,11 @@ export const licensingApi = baseApi
|
||||
* claim or a decision refreshes the badges along with the list and the
|
||||
* numbers can never drift from what the grid is showing.
|
||||
*/
|
||||
getQueueCounts: builder.query<QueueCounts, void>({
|
||||
query: () => ({ url: '/license-application-review/counts' }),
|
||||
getQueueCounts: builder.query<QueueCounts, string | void>({
|
||||
query: (licenseTypeKey) => ({
|
||||
url: '/license-application-review/counts',
|
||||
params: licenseTypeKey ? { licenseTypeKey } : undefined,
|
||||
}),
|
||||
providesTags: () => [listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
|
||||
@@ -173,6 +173,11 @@ export interface LicenseType {
|
||||
*/
|
||||
requiresOperatorMode: boolean;
|
||||
formSchema: { sections: FormSectionConfig[] };
|
||||
/**
|
||||
* Which course an application of this type runs. REGISTRATION skips the
|
||||
* evaluation and inspection stages, so those statuses are unreachable for it.
|
||||
*/
|
||||
workflowProfile?: WorkflowProfile;
|
||||
isActive: boolean;
|
||||
/** Display order set by EMA; lower comes first. */
|
||||
sortOrder: number;
|
||||
@@ -446,6 +451,9 @@ export type QueueSortField =
|
||||
| "dueAt"
|
||||
| "claimedAt";
|
||||
|
||||
/** Review → evaluation → (inspection) → approval, or the short registration course. */
|
||||
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
|
||||
|
||||
/** Row counts behind the queue's saved-view tabs. */
|
||||
export interface QueueCounts {
|
||||
unassigned: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreateMedicalCertificate,
|
||||
CreateSeaServiceRecord,
|
||||
MedicalCertificate,
|
||||
RecordQueueFilter,
|
||||
SeaServiceRecord,
|
||||
SeaTimeSummary,
|
||||
SeafarerStatus,
|
||||
@@ -104,13 +105,25 @@ export const seafarerApi = baseApi
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- verification
|
||||
getPendingSeaService: builder.query<SeaServiceRecord[], void>({
|
||||
query: () => ({ url: '/sea-service-records/pending' }),
|
||||
getPendingSeaService: builder.query<
|
||||
SeaServiceRecord[],
|
||||
RecordQueueFilter | void
|
||||
>({
|
||||
query: (status) => ({
|
||||
url: '/sea-service-records/pending',
|
||||
params: { status: status || 'SUBMITTED' },
|
||||
}),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
getPendingMedical: builder.query<MedicalCertificate[], void>({
|
||||
query: () => ({ url: '/medical-certificates/pending' }),
|
||||
getPendingMedical: builder.query<
|
||||
MedicalCertificate[],
|
||||
RecordQueueFilter | void
|
||||
>({
|
||||
query: (status) => ({
|
||||
url: '/medical-certificates/pending',
|
||||
params: { status: status || 'SUBMITTED' },
|
||||
}),
|
||||
providesTags: () => [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED';
|
||||
|
||||
/** Verification-queue filter: a record status, or ALL for no filter. */
|
||||
export type RecordQueueFilter = SeafarerRecordStatus | 'ALL';
|
||||
export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT';
|
||||
export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING';
|
||||
export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const queryApi = baseApi.injectEndpoints({
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useApiQueryQuery, useApiMutationMutation } = queryApi;
|
||||
export const { useApiQueryQuery, useLazyApiQueryQuery, useApiMutationMutation } = queryApi;
|
||||
|
||||
export function useApiQuery<TData = unknown>(
|
||||
args: ApiQueryArgs,
|
||||
@@ -37,6 +37,17 @@ export function useApiQuery<TData = unknown>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same endpoint as `useApiQuery`, fetched on demand instead of on render — for
|
||||
* the case where the arguments are only known at click time.
|
||||
*/
|
||||
export function useApiLazyQuery<TData = unknown>(): [
|
||||
(args: ApiQueryArgs) => { unwrap: () => Promise<TData> },
|
||||
] {
|
||||
const [trigger] = useLazyApiQueryQuery();
|
||||
return [trigger as unknown as (args: ApiQueryArgs) => { unwrap: () => Promise<TData> }];
|
||||
}
|
||||
|
||||
type UseApiMutationResult<TData> = {
|
||||
data: TData | undefined;
|
||||
isLoading: boolean;
|
||||
|
||||
Reference in New Issue
Block a user