mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 15:18:12 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into feature/fayda-auth-and-email-notifications
This commit is contained in:
@@ -511,6 +511,16 @@ export const licensingApi = baseApi
|
||||
query: (id) => ({ url: `/licenses/${id}/certificate` }),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Same download link, backoffice side. Separate endpoint from
|
||||
* `getCertificateUrl`: the applicant route only ever hands the
|
||||
* certificate to its holder, and an officer reviewing what they just
|
||||
* issued is never the holder.
|
||||
*/
|
||||
getCertificateUrlForOfficer: builder.mutation<{ url: string }, string>({
|
||||
query: (id) => ({ url: `/licenses/${id}/certificate-backoffice` }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------- review
|
||||
getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({
|
||||
query: (params) => ({
|
||||
@@ -842,6 +852,53 @@ export const licensingApi = baseApi
|
||||
query: () => ({ url: '/license-application-review/officers' }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------- team-leader dispatch
|
||||
/**
|
||||
* Handing work out, and handing it back.
|
||||
*
|
||||
* `assignApplication` below only re-points an application already in
|
||||
* flight; these two *start* a stage, because under the push model
|
||||
* assignment is how work begins — nothing is claimed from a queue.
|
||||
*/
|
||||
assignInspector: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; inspectorId: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/assign-inspector`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
reportReview: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/report-review`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
reportInspection: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/report-inspection`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
// ----------------------------------------------------- workflow controls
|
||||
assignApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -856,6 +913,23 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Starts the review: the team leader hands the file to an employee.
|
||||
* `assign` above only re-points an application already in flight.
|
||||
*/
|
||||
assignReviewer: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; officerId: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/assign-reviewer`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
holdApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; reason: string }
|
||||
@@ -929,7 +1003,12 @@ export const licensingApi = baseApi
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
{ applicationId: string; scheduledDate: string; location?: string }
|
||||
{
|
||||
applicationId: string;
|
||||
scheduledDate: string;
|
||||
timeSlot: 'MORNING' | 'AFTERNOON';
|
||||
location?: string;
|
||||
}
|
||||
>({
|
||||
query: (body) => ({ url: '/inspections', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
@@ -1017,6 +1096,7 @@ export const {
|
||||
useGetMyLicensesQuery,
|
||||
useGetLicensesQuery,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetCertificateUrlForOfficerMutation,
|
||||
useGetApplicationPaymentQuery,
|
||||
usePatchSectionMutation,
|
||||
useAddStaffMutation,
|
||||
@@ -1050,6 +1130,10 @@ export const {
|
||||
useRevokeLicenseMutation,
|
||||
useReinstateLicenseMutation,
|
||||
useAssignApplicationMutation,
|
||||
useAssignReviewerMutation,
|
||||
useAssignInspectorMutation,
|
||||
useReportReviewMutation,
|
||||
useReportInspectionMutation,
|
||||
useHoldApplicationMutation,
|
||||
useResumeApplicationMutation,
|
||||
useEscalateApplicationMutation,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FamilyKind,
|
||||
FieldCondition,
|
||||
FormFieldConfig,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
@@ -63,7 +64,10 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
REVIEW_REPORTED: 'Review Reported',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
INSPECTION_REPORTED: 'Inspection Reported',
|
||||
INSPECTION_FAILED: 'Inspection Failed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
@@ -90,7 +94,13 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
UNDER_EVALUATION: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'cyan',
|
||||
// Both 'reported' states are waiting on the team leader, so they carry the
|
||||
// same tone as anything else awaiting an officer decision.
|
||||
REVIEW_REPORTED: 'orange',
|
||||
INSPECTION_COMPLETED: 'cyan',
|
||||
INSPECTION_REPORTED: 'orange',
|
||||
// Orange, not red: recoverable — a re-inspection can still pass.
|
||||
INSPECTION_FAILED: 'orange',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
ON_HOLD: 'gray',
|
||||
@@ -123,7 +133,11 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
UNDER_EVALUATION: 45,
|
||||
RESUBMIT_REQUIRED: 30,
|
||||
INSPECTION_PENDING: 55,
|
||||
REVIEW_REPORTED: 50,
|
||||
INSPECTION_COMPLETED: 65,
|
||||
INSPECTION_REPORTED: 70,
|
||||
// A re-inspection returns to the pending step, so no further along than it.
|
||||
INSPECTION_FAILED: 55,
|
||||
APPROVED: 75,
|
||||
// Parked, so it keeps the progress of wherever it was held from.
|
||||
ON_HOLD: 45,
|
||||
@@ -177,6 +191,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'VESSEL_REGISTRATION',
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
@@ -188,6 +203,7 @@ const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
|
||||
BTC_BASIC_TRAINING: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
|
||||
ENDORSEMENT_SEAFARER: 'CERTIFICATE',
|
||||
ENDORSEMENT_COC: 'CERTIFICATE',
|
||||
ENDORSEMENT_GOC: 'CERTIFICATE',
|
||||
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
|
||||
@@ -371,6 +387,10 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
application_not_awaiting_inspection:
|
||||
'This application is not waiting for an inspection.',
|
||||
inspection_already_completed: 'This inspection has already been recorded.',
|
||||
inspection_not_yet_due:
|
||||
'Inspection results can be recorded after the scheduled inspection date and time.',
|
||||
inspection_not_passed:
|
||||
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
};
|
||||
|
||||
@@ -555,7 +575,14 @@ export function validateSections(
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Evaluates a config condition against the current form answers. */
|
||||
/**
|
||||
* Evaluates a config condition against the current form answers.
|
||||
*
|
||||
* Mirrors the server's `ApplicationValidationService.conditionHolds` —
|
||||
* `anyOf` holds when any listed sub-condition holds, needed for an answer
|
||||
* that can live on one of several mutually-exclusive fields (e.g. a CoP rank
|
||||
* split by department).
|
||||
*/
|
||||
interface ConditionLike {
|
||||
field?: string;
|
||||
equals?: unknown;
|
||||
@@ -567,7 +594,7 @@ interface ConditionLike {
|
||||
}
|
||||
|
||||
export function conditionHolds(
|
||||
condition: ConditionLike | undefined | null,
|
||||
condition: FieldCondition | undefined | null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!condition) return true;
|
||||
|
||||
@@ -26,9 +26,17 @@ export type LicenseStatus =
|
||||
| "SUBMITTED"
|
||||
| "UNDER_REVIEW"
|
||||
| "UNDER_EVALUATION"
|
||||
// Employee filed their review; parked with the team leader for a decision.
|
||||
| "REVIEW_REPORTED"
|
||||
| "RESUBMIT_REQUIRED"
|
||||
| "INSPECTION_PENDING"
|
||||
| "INSPECTION_COMPLETED"
|
||||
// Inspector filed the result; parked with the team leader for a decision.
|
||||
| "INSPECTION_REPORTED"
|
||||
// The inspection was conducted and failed. Approval and issuance are
|
||||
// unreachable until a re-inspection passes; the officer chooses between a
|
||||
// repeat visit, an adjustment round, and rejection.
|
||||
| "INSPECTION_FAILED"
|
||||
| "APPROVED"
|
||||
| "REJECTED"
|
||||
| "ON_HOLD"
|
||||
@@ -74,9 +82,9 @@ export interface FieldCondition {
|
||||
in?: (string | number)[];
|
||||
isSet?: boolean;
|
||||
/**
|
||||
* Holds when ANY listed condition holds — for a value that can live on one
|
||||
* of several mutually-exclusive fields (e.g. a rank split by department).
|
||||
* `field`/`equals`/etc are ignored when this is present.
|
||||
* Alternative to a single-field check: holds when ANY listed condition
|
||||
* holds. `field`/`equals`/etc are ignored when this is present. Mirrors
|
||||
* the server's `FieldCondition` (form-schema.type.ts).
|
||||
*/
|
||||
anyOf?: FieldCondition[];
|
||||
}
|
||||
@@ -446,6 +454,8 @@ export interface Inspection {
|
||||
inspectorId: string | null;
|
||||
inspectorName: string | null;
|
||||
scheduledDate: string | null;
|
||||
/** Half-day slot the site visit is booked into. */
|
||||
timeSlot: "MORNING" | "AFTERNOON" | null;
|
||||
conductedDate: string | null;
|
||||
location: string | null;
|
||||
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
|
||||
@@ -543,6 +553,13 @@ export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export interface TemplatePageOptions {
|
||||
format?: "A4" | "A5" | "Letter" | "Legal";
|
||||
/**
|
||||
* Explicit page dimensions (e.g. "4.92in"), for a size `format` has no
|
||||
* named preset for — an ID-3 passport-booklet page, for one. Takes
|
||||
* precedence over `format` when both are present.
|
||||
*/
|
||||
width?: string;
|
||||
height?: string;
|
||||
landscape?: boolean;
|
||||
printBackground?: boolean;
|
||||
}
|
||||
@@ -572,12 +589,15 @@ export interface TemplateFieldPlacement {
|
||||
/** Variable rendered here, or null when the block carries literal `text`. */
|
||||
variable: string | null;
|
||||
text?: string;
|
||||
/** Renders as `<img>` when "image" — see TemplateVariable.kind. */
|
||||
type?: "text" | "image";
|
||||
xPct: number;
|
||||
yPct: number;
|
||||
widthPct: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: "normal" | "bold";
|
||||
align?: "left" | "center" | "right";
|
||||
fontStyle?: "normal" | "italic";
|
||||
align?: "left" | "center" | "right" | "justify";
|
||||
color?: string;
|
||||
}
|
||||
|
||||
@@ -639,6 +659,8 @@ export interface LicenseTemplate {
|
||||
export interface TemplateVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
/** "image" means the value is a data URI to place as `<img>`, not text. */
|
||||
kind?: "text" | "image";
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
|
||||
@@ -36,6 +36,11 @@ export const seafarerRegistrationApi = baseApi
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
cancelSeafarerRegistration: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/seafarer-registrations/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
saveSeafarerRegistration: builder.mutation<
|
||||
SeafarerRegistration,
|
||||
{ id: string; body: SaveSeafarerRegistration }
|
||||
@@ -108,6 +113,7 @@ export const seafarerRegistrationApi = baseApi
|
||||
export const {
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useStartSeafarerRegistrationMutation,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
useSubmitSeafarerRegistrationMutation,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
|
||||
@@ -64,13 +64,28 @@ export const PHYSICAL_BOUNDS = {
|
||||
weightKg: { min: 30, max: 250 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* How the platform spells Ethiopia in the `nationality` free-text field
|
||||
* (CountrySelect's country name — matches the API's ETHIOPIAN_NATIONALITY).
|
||||
*/
|
||||
export const ETHIOPIAN_NATIONALITY = 'Ethiopia';
|
||||
|
||||
/** Whether a declared nationality is Ethiopian — drives National ID vs Passport requirements. */
|
||||
export function isEthiopianNationality(nationality: string | null | undefined): boolean {
|
||||
return nationality === ETHIOPIAN_NATIONALITY;
|
||||
}
|
||||
|
||||
/** Upload slots, keyed as the API's submission check expects them. */
|
||||
export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `'passport'`: required only once a passport number is declared. */
|
||||
required: boolean | 'passport';
|
||||
/**
|
||||
* `'passport'`: required once a passport number is declared (always true
|
||||
* for non-Ethiopians, who must declare one). `'ethiopian'`: required only
|
||||
* for applicants who declared Ethiopian nationality.
|
||||
*/
|
||||
required: boolean | 'passport' | 'ethiopian';
|
||||
accept?: string;
|
||||
}[] = [
|
||||
{
|
||||
@@ -80,7 +95,7 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
required: true,
|
||||
accept: 'image/jpeg,image/png',
|
||||
},
|
||||
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: true },
|
||||
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: 'ethiopian' },
|
||||
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
|
||||
{ key: 'graduation', name: 'Educational Certificate', required: false },
|
||||
{
|
||||
@@ -210,14 +225,22 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
||||
bloodType: BLOOD_TYPE_OPTIONS,
|
||||
};
|
||||
|
||||
/** Display value for one answer: option label for enums, "—" when blank. */
|
||||
/**
|
||||
* Display value for one answer: option label for enums, "—" when blank.
|
||||
*
|
||||
* `departmentOptions` overrides the hardcoded `DEPARTMENT_OPTIONS` fallback
|
||||
* for the `department` field — the live list from `GET /departments`, so a
|
||||
* department added in the backoffice after this constants file was written
|
||||
* still gets its name instead of falling back to the raw code.
|
||||
*/
|
||||
export function displaySeafarerAnswer(
|
||||
field: keyof SeafarerRegistrationAnswers,
|
||||
value: unknown,
|
||||
departmentOptions?: { value: string; label: string }[],
|
||||
): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
const options = OPTION_LABELS[field];
|
||||
const options = field === 'department' && departmentOptions ? departmentOptions : OPTION_LABELS[field];
|
||||
if (options) return options.find((o) => o.value === value)?.label ?? String(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -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:3000` 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