mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 15:18:12 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user