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

This commit is contained in:
Nati
2026-08-29 06:52:20 +00:00
55 changed files with 2138 additions and 231 deletions

View File

@@ -8,5 +8,5 @@ export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/biometric-enrollment';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
/**
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
* falling back to the local dev API (3001 — the portal itself owns 3000 for
* the Fayda redirect). Import this; do not re-derive it.
*/
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
]?.trim() || "http://localhost:3001/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -13,6 +13,7 @@ import type {
InitiatePaymentResult,
IssuedLicense,
Inspection,
IssuancePeriod,
LicenseApplication,
LicenseCategoryDefinition,
LicenseStatus,
@@ -26,6 +27,9 @@ import type {
ExportResult,
LicenseTemplate,
Paginated,
PickupAppointment,
PickupOffice,
PickupSlot,
QueueCounts,
QueueFilter,
Rank,
@@ -75,6 +79,8 @@ const TAGS = [
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
'PickupOffice',
'PickupAppointment',
'Department',
'Rank',
] as const;
@@ -980,12 +986,12 @@ export const licensingApi = baseApi
scheduleIssuance: builder.mutation<
LicenseApplication,
{ id: string; scheduledDate: string }
{ id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod }
>({
query: ({ id, scheduledDate }) => ({
query: ({ id, scheduledDate, scheduledPeriod }) => ({
url: `/license-application-review/${id}/schedule-issuance`,
method: 'POST',
body: { scheduledDate },
body: { scheduledDate, scheduledPeriod },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
@@ -1000,6 +1006,104 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// ------------------------------------------------------------- pickup
getPickupOffices: builder.query<PickupOffice[], void>({
query: () => ({ url: '/pickup/offices' }),
providesTags: [listTag('PickupOffice')],
}),
getPickupSlots: builder.query<
PickupSlot[],
{ officeId: string; from: string; to: string }
>({
query: ({ officeId, from, to }) => ({
url: `/pickup/offices/${officeId}/slots`,
params: { from, to },
}),
}),
schedulePickup: builder.mutation<
PickupAppointment,
{ applicationId: string; officeId: string; date: string; slotStartTime: string }
>({
query: (body) => ({ url: '/pickup/appointments', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) =>
error
? []
: [
itemTag('LicenseApplication', applicationId),
listTag('ApplicationQueue'),
listTag('PickupAppointment'),
],
}),
reschedulePickup: builder.mutation<
PickupAppointment,
{ appointmentId: string; officeId: string; date: string; slotStartTime: string }
>({
query: ({ appointmentId, ...body }) => ({
url: `/pickup/appointments/${appointmentId}/reschedule`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { appointmentId }) =>
error ? [] : [itemTag('PickupAppointment', appointmentId), listTag('PickupAppointment')],
}),
getPickupAppointmentsForApplication: builder.query<PickupAppointment[], string>({
query: (applicationId) => ({
url: `/pickup/applications/${applicationId}/appointments`,
}),
providesTags: (_r, _e, applicationId) => [itemTag('PickupAppointment', applicationId)],
}),
getPickupWorklist: builder.query<
PickupAppointment[],
{ date: string; officeId?: string }
>({
query: ({ date, officeId }) => ({
url: '/pickup/appointments',
params: officeId ? { date, officeId } : { date },
}),
providesTags: [listTag('PickupAppointment')],
}),
checkInPickup: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupIssued: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupNoShow: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
createPickupOffice: builder.mutation<PickupOffice, Partial<PickupOffice>>({
query: (body) => ({ url: '/pickup/offices', method: 'POST', body }),
invalidatesTags: [listTag('PickupOffice')],
}),
updatePickupOffice: builder.mutation<
PickupOffice,
{ id: string } & Partial<PickupOffice>
>({
query: ({ id, ...body }) => ({
url: `/pickup/offices/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')],
}),
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
@@ -1175,6 +1279,17 @@ export const {
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useGetPickupOfficesQuery,
useGetPickupSlotsQuery,
useSchedulePickupMutation,
useReschedulePickupMutation,
useGetPickupAppointmentsForApplicationQuery,
useGetPickupWorklistQuery,
useCheckInPickupMutation,
useMarkPickupIssuedMutation,
useMarkPickupNoShowMutation,
useCreatePickupOfficeMutation,
useUpdatePickupOfficeMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetInspectionsQuery,

View File

@@ -1,6 +1,8 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
import type {
ApplicationKind,
Bilingual,
FamilyKind,
FieldCondition,
@@ -11,9 +13,10 @@ import type {
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
}
/**
* Uploads a document straight to the API.
@@ -452,12 +455,28 @@ export function buildWizardSteps(
* instead of showing an empty page.
*/
hasStaff?: boolean;
/**
* Whether this application has any document requirements to upload.
* False for a Damaged/Reissue application, which asks nothing beyond the
* Damage Information step — showing an empty Documents page would be a
* page to click past for nothing.
*/
hasDocuments?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
/**
* The application's kind — NEW unless the caller is renewing or
* reissuing. A section scoped to a different kind via
* `applicationKinds` is left out entirely, the same as a `showWhen`
* that never holds.
*/
applicationKind?: ApplicationKind;
},
): WizardStep[] {
const kind = options?.applicationKind ?? 'NEW';
const visible = [...sections]
.filter((section) => sectionAppliesToKind(section, kind))
.filter((section) => conditionHolds(section.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
@@ -512,7 +531,9 @@ export function buildWizardSteps(
...(options?.hasStaff === false
? []
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
...(options?.hasDocuments === false
? []
: [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];
}

View File

@@ -63,7 +63,10 @@ export type LicenseStatus =
| "EXAM_PASSED"
| "EXAM_FAILED";
export type ApplicationKind = "NEW" | "RENEWAL";
export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE";
/** Half-day window a team leader books an applicant's document pickup into. */
export type IssuancePeriod = "MORNING" | "AFTERNOON";
export type FormFieldType =
| "TEXT"
@@ -120,6 +123,12 @@ export interface FormSectionConfig {
group?: string;
/** Position of the group in the stepper; lowest value in a group wins. */
groupOrder?: number;
/**
* Restricts this section to specific application kinds — e.g. the
* Damaged/Reissue "Damage Information" step. Undefined or empty means
* every kind.
*/
applicationKinds?: ApplicationKind[];
}
/** Grouping the portal organises the licence catalogue by. */
@@ -349,6 +358,7 @@ export interface LicenseApplication {
issuedLicenseId: string | null;
/** Set once an officer schedules pickup for a document requiring in-person handover. */
scheduledIssuanceDate: string | null;
scheduledIssuancePeriod: IssuancePeriod | null;
scheduledBy: string | null;
createdAt: string;
}
@@ -440,6 +450,13 @@ export interface ApplicationApplicant {
export interface ApplicationDetail {
application: LicenseApplication;
/**
* Current status of the license this application issued, independent of
* the application's own (permanently historical) status — a later
* reissue/renewal can supersede the license without changing what this
* application itself accomplished. Null when nothing has been issued yet.
*/
issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null;
relatedApplications?: LicenseApplication[];
/** Null when the applicant has no profile row (never expected in practice). */
applicant: ApplicationApplicant | null;
@@ -466,6 +483,50 @@ export interface Inspection {
findings: string | null;
}
export type PickupAppointmentStatus =
| "SCHEDULED"
| "CHECKED_IN"
| "ISSUED"
| "NO_SHOW"
| "RESCHEDULED"
| "CANCELLED";
export interface PickupOffice {
id: string;
name: string;
address: string | null;
/** 0=Sunday .. 6=Saturday. */
workingDays: number[];
startTime: string;
endTime: string;
slotDurationMinutes: number;
maxApplicantsPerSlot: number;
rescheduleMinNoticeHours: number;
isActive: boolean;
}
export interface PickupSlot {
date: string;
slotStartTime: string;
capacity: number;
booked: number;
available: number;
}
export interface PickupAppointment {
id: string;
applicationId: string;
appointmentNumber: string;
officeId: string;
date: string;
slotStartTime: string;
status: PickupAppointmentStatus;
rescheduledFromId: string | null;
rescheduleCount: number;
checkedInAt: string | null;
checkedInById: string | null;
}
export interface AppNotification {
id: string;
subject: Bilingual;
@@ -482,6 +543,7 @@ export interface QueueFilter {
licenseTypeId?: string;
search?: string;
status?: LicenseStatus[];
kind?: ApplicationKind;
/** Officer uuid, or the literal 'unassigned'. */
assignee?: string;
submittedFrom?: string;
@@ -747,6 +809,8 @@ export interface IssuedLicense {
* configuration.
*/
renewable?: boolean;
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
reissuable?: boolean;
verificationCode: string;
certificateFileKey: string | null;
}

View File

@@ -4,6 +4,7 @@ import type {
SeafarerDocument,
SeafarerDocumentDetail,
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentRow,
SeafarerDocumentStatus,
} from './seafarer-document.types';
@@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const;
export interface SeafarerDocumentListFilter {
kind?: SeafarerDocumentKind;
requestKind?: SeafarerDocumentRequestKind;
status?: SeafarerDocumentStatus;
search?: string;
take?: number;
@@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
renewSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/renew`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
replaceSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/replace`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
// --------------------------------------------------------------- review
listSeafarerDocuments: builder.query<
{ total: number; items: SeafarerDocumentRow[] },
@@ -121,6 +133,8 @@ export const {
useInitiateDocumentPaymentMutation,
useGetDocumentPaymentQuery,
useBypassDocumentPaymentMutation,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
useListSeafarerDocumentsQuery,
useGetSeafarerDocumentReviewQuery,
useLazyGetSeafarerDocumentReviewDownloadQuery,

View File

@@ -1,10 +1,26 @@
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
import type {
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentStatus,
} from './seafarer-document.types';
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_LABELS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'New',
RENEWAL: 'Renewal',
REPLACEMENT: 'Replacement',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_COLORS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'gray',
RENEWAL: 'blue',
REPLACEMENT: 'orange',
};
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
AWAITING_REGISTRATION: 'Awaiting Registration',
PAYMENT_PENDING: 'Payment Pending',

View File

@@ -12,14 +12,19 @@ export type SeafarerDocumentStatus =
| 'REJECTED'
| 'CANCELLED';
/** A Seaman Book or BTC request — opened by a seafarer registration. */
/** NEW comes from a seafarer registration; RENEWAL/REPLACEMENT are applicant-initiated. */
export type SeafarerDocumentRequestKind = 'NEW' | 'RENEWAL' | 'REPLACEMENT';
/** A Seaman Book or BTC request — opened by a seafarer registration, or by the applicant as a renewal/replacement. */
export interface SeafarerDocument {
id: string;
kind: SeafarerDocumentKind;
requestKind: SeafarerDocumentRequestKind;
requestNumber: string;
applicantUserId: string;
profileId: string | null;
seafarerRegistrationId: string | null;
previousDocumentId: string | null;
status: SeafarerDocumentStatus;
feeAmount: number | null;
feeCurrency: string;

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import Cookies from "js-cookie";
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
tenantId: "x-tenant-id",
organizationUnitId: "x-organization-unit-id",
currentPositionId: "x-current-position-id",
currentProjectId: "x-current-project-id",
} as const;
/**
* Which app this bundle is, so it reads its own session and no one else's.
*
* Set by each app's store via `configureSessionScope`. Cookies ignore the
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
* port, so `localhost:3001` and `localhost:4201` share one jar: without a
* scope the backoffice would happily authenticate as whoever last signed into
* the portal, and render a staff console with an applicant's permissions.
*/
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
const LEGACY_TOKEN_KEY = "auth-token";
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's: