Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-08 09:03:50 +03:00
66 changed files with 6665 additions and 3846 deletions

View File

@@ -1,9 +1,8 @@
export * from "./lib/base-api";
export * from "./lib/query-and-mutation";
export * from "./lib/session";
export * from "./lib/features/licensing";
export * from "./lib/features/licensing/licensing-api";
export {
baseQueryWithReauth,
configureTokenRefresh,
} from "./lib/base-api/base-query-with-reauth";
export * from './lib/base-api';
export * from './lib/query-and-mutation';
export * from './lib/session';
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';

View File

@@ -2,7 +2,7 @@ import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { resolveSessionContext } from '../session';
const BASE_API_URL =
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';

View File

@@ -0,0 +1,43 @@
import { resolveTokenFromStorage } from '../session';
import { BASE_API_URL } from './base-query-with-reauth';
/**
* Opens an authenticated binary endpoint (a rendered PDF) in a new tab.
*
* RTK Query is not used here: `fetchBaseQuery` parses responses as JSON, and
* a plain `window.open` sends no Authorization header, so a guarded document
* endpoint would answer 401. Fetching to a blob keeps the bearer token on the
* request and still gives the browser something it can display.
*/
export async function openAuthedDocument(
path: string,
fallbackName = 'document.pdf',
): Promise<void> {
const token = resolveTokenFromStorage();
const response = await fetch(`${BASE_API_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
// The body carries the API's error key, which callers surface verbatim.
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 opened = window.open(url, '_blank', 'noopener');
if (!opened) {
// Pop-up blocked: fall back to a direct download so the click still works.
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fallbackName;
anchor.click();
}
// Revoking immediately would race the new tab's load.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}

View File

@@ -308,6 +308,39 @@ export const licensingApi = baseApi
}),
// ------------------------------------------------------------ licences
/**
* Public QR-code verification (US-PORTAL-008). No auth required —
* the endpoint returns only what a verifier needs to trust the
* document, never the holder's contact details.
*/
verifyCertificate: builder.query<
{
valid: boolean;
reason?: string;
certificateNumber?: string;
licenseType?: string | null;
companyName?: string | null;
issueDate?: string;
expiryDate?: string;
status?: string;
},
string
>({
query: (code) => ({ url: `/licenses/verify/${code}` }),
}),
/** The licence register for enforcement officers. */
getLicenses: builder.query<
Paginated<IssuedLicense>,
{ search?: string } | void
>({
query: (args) => ({
url: '/licenses',
params: args?.search ? { search: args.search } : undefined,
}),
providesTags: () => [listTag('License')],
}),
getMyLicenses: builder.query<Paginated<IssuedLicense>, void>({
query: () => ({ url: '/licenses/mine' }),
providesTags: () => [listTag('License')],
@@ -693,12 +726,24 @@ export const licensingApi = baseApi
recordInspectionResult: builder.mutation<
Inspection,
{ inspectionId: string; applicationId: string; result: 'PASSED' | 'FAILED'; findings: string }
{
inspectionId: string;
applicationId: string;
result: 'PASSED' | 'FAILED';
findings: string;
/** Structured per-item outcomes (office, warehouse, vehicles, …). */
checklist?: {
key: string;
label: string;
outcome: 'PASS' | 'FAIL' | 'NEEDS_CORRECTION';
note?: string;
}[];
}
>({
query: ({ inspectionId, result, findings }) => ({
query: ({ inspectionId, result, findings, checklist }) => ({
url: `/inspections/${inspectionId}/result`,
method: 'PATCH',
body: { result, findings },
body: { result, findings, ...(checklist?.length ? { checklist } : {}) },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
@@ -724,6 +769,7 @@ export const licensingApi = baseApi
});
export const {
useVerifyCertificateQuery,
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
@@ -736,6 +782,7 @@ export const {
useBypassPaymentMutation,
useGetPaymentCapabilitiesQuery,
useGetMyLicensesQuery,
useGetLicensesQuery,
useGetCertificateUrlMutation,
useGetApplicationPaymentQuery,
usePatchSectionMutation,

View File

@@ -18,7 +18,13 @@ const BASE_API_URL =
* attach them to, and nothing can be lost if the browser closes mid-wizard.
*/
export async function uploadDocument(params: {
ownerType: 'APPLICATION' | 'APPLICATION_STAFF' | 'INSPECTION' | 'LICENSE';
ownerType:
| 'APPLICATION'
| 'APPLICATION_STAFF'
| 'INSPECTION'
| 'LICENSE'
| 'SEA_SERVICE_RECORD'
| 'MEDICAL_CERTIFICATE';
ownerId: string;
documentKey: string;
file: File;
@@ -210,6 +216,14 @@ export interface WizardStep {
export function buildWizardSteps(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
options?: {
/**
* Whether this licence type has staff-role requirements at all. Types
* without any (seafarer registration) skip the Staff step entirely
* instead of showing an empty page.
*/
hasStaff?: boolean;
},
): WizardStep[] {
const visible = [...sections]
.filter((section) => conditionHolds(section.showWhen, formData))
@@ -259,7 +273,9 @@ export function buildWizardSteps(
return [
...formSteps,
{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] },
...(options?.hasStaff === false
? []
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];

View File

@@ -75,7 +75,10 @@ export interface FormSectionConfig {
/** Grouping the portal organises the licence catalogue by. */
export type LicenseCategory =
"CARGO_FREIGHT" | "SHIPPING_AGENCY" | "INVESTMENT";
| 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY'
| 'INVESTMENT'
| 'MARITIME_PERSONNEL';
export interface LicenseCategoryDefinition {
key: LicenseCategory;
@@ -117,6 +120,12 @@ export interface LicenseType {
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
/**
* False for person-centric registrations (seafarer): they are open to any
* authenticated applicant, live outside the operator catalogue, and have
* their own entry points.
*/
requiresOperatorMode: boolean;
formSchema: { sections: FormSectionConfig[] };
isActive: boolean;
/** Display order set by EMA; lower comes first. */

View File

@@ -0,0 +1,2 @@
export * from './seafarer.types';
export * from './seafarer-api';

View File

@@ -0,0 +1,188 @@
import { baseApi } from '../../base-api';
import type {
CreateMedicalCertificate,
CreateSeaServiceRecord,
MedicalCertificate,
SeaServiceRecord,
SeaTimeSummary,
SeafarerStatus,
} from './seafarer.types';
const TAGS = ['SeaServiceRecord', 'MedicalCertificate'] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
/**
* The seafarer's evidence shelf: sea-service records and medical
* certificates (modules 05/06). Registration itself rides the licensing
* endpoints — a SEAFARER_REGISTRATION application through `licensingApi`.
*/
export const seafarerApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })
.injectEndpoints({
endpoints: (builder) => ({
// ---------------------------------------------------------- sea service
getMySeaServiceRecords: builder.query<SeaServiceRecord[], void>({
query: () => ({ url: '/sea-service-records/mine' }),
providesTags: () => [listTag('SeaServiceRecord')],
}),
getSeaServiceForProfile: builder.query<SeaServiceRecord[], string>({
query: (profileId) => ({
url: `/sea-service-records/profile/${profileId}`,
}),
providesTags: () => [listTag('SeaServiceRecord')],
}),
createSeaServiceRecord: builder.mutation<
SeaServiceRecord,
CreateSeaServiceRecord
>({
query: (body) => ({ url: '/sea-service-records', method: 'POST', body }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
updateSeaServiceRecord: builder.mutation<
SeaServiceRecord,
{ id: string; body: Partial<CreateSeaServiceRecord> }
>({
query: ({ id, body }) => ({
url: `/sea-service-records/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
deleteSeaServiceRecord: builder.mutation<unknown, string>({
query: (id) => ({ url: `/sea-service-records/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
// -------------------------------------------------------------- medical
getMyMedicalCertificates: builder.query<MedicalCertificate[], void>({
query: () => ({ url: '/medical-certificates/mine' }),
providesTags: () => [listTag('MedicalCertificate')],
}),
getMedicalForProfile: builder.query<MedicalCertificate[], string>({
query: (profileId) => ({
url: `/medical-certificates/profile/${profileId}`,
}),
providesTags: () => [listTag('MedicalCertificate')],
}),
createMedicalCertificate: builder.mutation<
MedicalCertificate,
CreateMedicalCertificate
>({
query: (body) => ({ url: '/medical-certificates', method: 'POST', body }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
updateMedicalCertificate: builder.mutation<
MedicalCertificate,
{ id: string; body: Partial<CreateMedicalCertificate> }
>({
query: ({ id, body }) => ({
url: `/medical-certificates/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
deleteMedicalCertificate: builder.mutation<unknown, string>({
query: (id) => ({ url: `/medical-certificates/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
// -------------------------------------------------------- verification
getPendingSeaService: builder.query<SeaServiceRecord[], void>({
query: () => ({ url: '/sea-service-records/pending' }),
providesTags: () => [listTag('SeaServiceRecord')],
}),
getPendingMedical: builder.query<MedicalCertificate[], void>({
query: () => ({ url: '/medical-certificates/pending' }),
providesTags: () => [listTag('MedicalCertificate')],
}),
verifySeaServiceRecord: builder.mutation<
SeaServiceRecord,
{ id: string; outcome: 'VERIFIED' | 'REJECTED'; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/sea-service-records/${id}/verify`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
verifyMedicalCertificate: builder.mutation<
MedicalCertificate,
{ id: string; outcome: 'VERIFIED' | 'REJECTED'; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/medical-certificates/${id}/verify`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
getMySeaTime: builder.query<SeaTimeSummary, void>({
query: () => ({ url: '/sea-service-records/mine/sea-time' }),
providesTags: () => [listTag('SeaServiceRecord')],
}),
getSeaTimeForProfile: builder.query<SeaTimeSummary, string>({
query: (profileId) => ({
url: `/sea-service-records/profile/${profileId}/sea-time`,
}),
providesTags: () => [listTag('SeaServiceRecord')],
}),
// ----------------------------------------------- registry (backoffice)
/** US-SEA-013: suspend, reinstate or close with a mandatory reason. */
updateSeafarerStatus: builder.mutation<
unknown,
{ profileId: string; status: SeafarerStatus; reason: string }
>({
query: ({ profileId, ...body }) => ({
url: `/profiles/${profileId}/seafarer-status`,
method: 'POST',
body,
}),
}),
}),
});
export const {
useGetPendingSeaServiceQuery,
useGetPendingMedicalQuery,
useVerifySeaServiceRecordMutation,
useVerifyMedicalCertificateMutation,
useGetMySeaTimeQuery,
useGetSeaTimeForProfileQuery,
useGetMySeaServiceRecordsQuery,
useGetSeaServiceForProfileQuery,
useCreateSeaServiceRecordMutation,
useUpdateSeaServiceRecordMutation,
useDeleteSeaServiceRecordMutation,
useGetMyMedicalCertificatesQuery,
useGetMedicalForProfileQuery,
useCreateMedicalCertificateMutation,
useUpdateMedicalCertificateMutation,
useDeleteMedicalCertificateMutation,
useUpdateSeafarerStatusMutation,
} = seafarerApi;

View File

@@ -0,0 +1,78 @@
export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED';
export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT';
export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING';
export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';
/** One engagement aboard one vessel (US-SSM-001). */
/** Denormalised owner summary, present on the verification queues. */
export interface SeafarerProfileSummary {
id: string;
firstName: string | null;
middleName: string | null;
lastName: string | null;
seafarerNumber?: string | null;
}
export interface SeaServiceRecord {
id: string;
profileId: string;
profile?: SeafarerProfileSummary;
vesselName: string;
imoNumber: string | null;
vesselType: string | null;
flagState: string | null;
grossTonnage: string | number | null;
rank: string;
engagementDate: string;
dischargeDate: string;
dutiesDescription: string | null;
status: SeafarerRecordStatus;
verifiedById: string | null;
verifiedAt: string | null;
verificationRemark: string | null;
createdAt: string;
}
export interface CreateSeaServiceRecord {
vesselName: string;
imoNumber?: string;
vesselType?: string;
flagState?: string;
grossTonnage?: number;
rank: string;
engagementDate: string;
dischargeDate: string;
dutiesDescription?: string;
}
/** An STCW medical fitness certificate (US-SSM-006). */
export interface MedicalCertificate {
id: string;
profileId: string;
profile?: SeafarerProfileSummary;
issuerName: string;
certificateNumber: string | null;
issueDate: string;
expiryDate: string;
fitnessStatus: MedicalFitness;
restrictions: string | null;
status: SeafarerRecordStatus;
verifiedById: string | null;
verifiedAt: string | null;
verificationRemark: string | null;
createdAt: string;
}
export interface CreateMedicalCertificate {
issuerName: string;
certificateNumber?: string;
issueDate: string;
expiryDate: string;
fitnessStatus?: MedicalFitness;
restrictions?: string;
}
export interface SeaTimeSummary {
totalDays: number;
verifiedRecords: number;
}

View File

@@ -0,0 +1,2 @@
export * from './vessel.types';
export * from './vessel-api';

View File

@@ -0,0 +1,84 @@
import { baseApi } from '../../base-api';
import type {
CreateVesselIncident,
Vessel,
VesselIncident,
VesselStatus,
} from './vessel.types';
const TAGS = ['Vessel', 'VesselIncident'] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
/**
* The vessel register (module 11). Registration applications themselves go
* through `licensingApi` as the VESSEL_REGISTRATION type; these endpoints
* serve the register the certificates produce.
*/
export const vesselApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })
.injectEndpoints({
endpoints: (builder) => ({
getMyVessels: builder.query<Vessel[], void>({
query: () => ({ url: '/vessels/mine' }),
providesTags: () => [listTag('Vessel')],
}),
getVessels: builder.query<
{ total: number; items: Vessel[] },
{ search?: string } | void
>({
query: (args) => ({
url: '/vessels',
params: args?.search ? { search: args.search } : undefined,
}),
providesTags: () => [listTag('Vessel')],
}),
getVessel: builder.query<Vessel, string>({
query: (id) => ({ url: `/vessels/${id}` }),
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
}),
/** Suspend / deregister / reinstate with a mandatory reason. */
updateVesselStatus: builder.mutation<
Vessel,
{ vesselId: string; status: VesselStatus; reason: string }
>({
query: ({ vesselId, ...body }) => ({
url: `/vessels/${vesselId}/status`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { vesselId }) =>
error ? [] : [listTag('Vessel'), { type: 'Vessel', id: vesselId }],
}),
getVesselIncidents: builder.query<VesselIncident[], string>({
query: (vesselId) => ({ url: `/vessels/${vesselId}/incidents` }),
providesTags: () => [listTag('VesselIncident')],
}),
createVesselIncident: builder.mutation<
VesselIncident,
{ vesselId: string; body: CreateVesselIncident }
>({
query: ({ vesselId, body }) => ({
url: `/vessels/${vesselId}/incidents`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('VesselIncident')],
}),
}),
});
export const {
useGetMyVesselsQuery,
useGetVesselsQuery,
useGetVesselQuery,
useUpdateVesselStatusMutation,
useGetVesselIncidentsQuery,
useCreateVesselIncidentMutation,
} = vesselApi;

View File

@@ -0,0 +1,51 @@
export type VesselCategory = 'INLAND_WATERWAY' | 'SEA_GOING';
export type VesselStatus = 'REGISTERED' | 'SUSPENDED' | 'DEREGISTERED';
/** A vessel-register entry, written when a registration certificate issues. */
export interface Vessel {
id: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
imoNumber: string | null;
hullNumber: string | null;
flagState: string | null;
portOfRegistry: string | null;
grossTonnage: string | number | null;
passengerCapacity: number | null;
lengthMeters: string | number | null;
yearBuilt: number | null;
engineType: string | null;
enginePowerKw: string | number | null;
numberOfEngines: number | null;
hullMaterial: string | null;
ownerUserId: string;
ownerProfileId: string | null;
ownerName: string | null;
applicationId: string;
licenseId: string;
status: VesselStatus;
statusReason: string | null;
statusChangedAt: string | null;
registeredAt: string;
}
export interface VesselIncident {
id: string;
vesselId: string;
occurredAt: string;
location: string | null;
description: string;
severity: string | null;
reportedById: string;
reportedByOfficer: boolean;
createdAt: string;
}
export interface CreateVesselIncident {
occurredAt: string;
location?: string;
description: string;
severity?: string;
}