Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
nati
2026-09-08 08:29:16 +00:00
32 changed files with 7789 additions and 422 deletions

View File

@@ -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')],
}),

View File

@@ -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;

View File

@@ -1,3 +1,4 @@
export * from './seafarer.types';
export * from './seafarer-report.types';
export * from './seafarer-api';
export * from './seafarer.helpers';

View File

@@ -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,

View 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 };