Merge branch 'WorkflowChange' into logestic_chnage

This commit is contained in:
Nati Nigussie
2026-08-27 11:23:14 +03:00
committed by GitHub
92 changed files with 5309 additions and 1041 deletions

View File

@@ -1,10 +1,11 @@
import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { resolveSessionContext } from '../session';
import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;
@@ -28,7 +29,7 @@ export const baseQueryWithReauth: BaseQueryFn<
const { token, sessionHeaders } = resolveSessionContext(
api.getState() as { auth?: { token?: string } },
);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (token) headers.set("Authorization", `Bearer ${token}`);
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
return headers;
},

View File

@@ -6,6 +6,7 @@ import type {
ApplicationPayment,
ApplicationStaff,
Attachment,
Department,
DocumentRequirement,
FormSchemaPalette,
FormSectionConfig,
@@ -21,11 +22,14 @@ import type {
AssignableOfficer,
DocumentDecision,
DocumentReview,
EligibleExam,
ExportResult,
LicenseTemplate,
Paginated,
QueueCounts,
QueueFilter,
Rank,
RankCertificateCategory,
RemarkTargetType,
SavedQueueView,
SchemaIssue,
@@ -71,6 +75,8 @@ const TAGS = [
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
'Department',
'Rank',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -253,6 +259,75 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// --------------------------------------------------- departments & ranks
/** Every department, for the admin editor. */
getDepartments: builder.query<Paginated<Department>, void>({
query: () => ({ url: '/departments' }),
providesTags: () => [listTag('Department')],
}),
/** Active departments only — the applicant-facing picker. */
getActiveDepartments: builder.query<Department[], void>({
query: () => ({ url: '/departments/active/list' }),
providesTags: () => [listTag('Department')],
}),
createDepartment: builder.mutation<
Department,
Partial<Department> & { code: string; name: Department['name'] }
>({
query: (body) => ({ url: '/departments', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
updateDepartment: builder.mutation<Department, { id: string } & Partial<Department>>({
query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
deleteDepartment: builder.mutation<unknown, string>({
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
}),
/** Every rank, for the admin editor to filter/group by department client-side. */
getRanks: builder.query<Paginated<Rank>, void>({
query: () => ({ url: '/ranks' }),
providesTags: () => [listTag('Rank')],
}),
/** One department's ladder for a category, ordered — the applicant wizard's rank picker. */
getRankLadder: builder.query<
Rank[],
{ departmentId: string; certificateCategory: RankCertificateCategory }
>({
query: (params) => ({ url: '/ranks/ladder', params }),
providesTags: () => [listTag('Rank')],
}),
createRank: builder.mutation<
Rank,
Partial<Rank> & {
departmentId: string;
certificateCategory: RankCertificateCategory;
key: string;
name: Rank['name'];
}
>({
query: (body) => ({ url: '/ranks', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
updateRank: builder.mutation<Rank, { id: string } & Partial<Rank>>({
query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
deleteRank: builder.mutation<unknown, string>({
query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
}),
// -------------------------------------------------------- application
createApplication: builder.mutation<
LicenseApplication,
@@ -575,6 +650,15 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
/**
* Exam sittings valid for this application's rank — what the
* schedule-exam picker offers, instead of every exam in the system.
*/
getEligibleExams: builder.query<EligibleExam[], string>({
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
/** Places a candidate who has paid the examination fee into a sitting. */
scheduleExam: builder.mutation<
LicenseApplication,
@@ -641,6 +725,8 @@ export const licensingApi = baseApi
LicenseTemplate,
{
licenseTypeId: string;
/** Scopes the draft to one rank's certificate. Omit for the type's default design. */
rankId?: string | null;
name: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
@@ -654,6 +740,7 @@ export const licensingApi = baseApi
LicenseTemplate,
{
id: string;
rankId?: string | null;
name?: string;
hbsSource?: string;
pageOptions?: TemplatePageOptions;
@@ -839,6 +926,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 }
@@ -984,6 +1088,16 @@ export const {
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,
useUpdateDepartmentMutation,
useDeleteDepartmentMutation,
useGetRanksQuery,
useGetRankLadderQuery,
useCreateRankMutation,
useUpdateRankMutation,
useDeleteRankMutation,
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
@@ -1043,6 +1157,7 @@ export const {
useApproveDocumentsMutation,
useFinalApproveMutation,
useRejectApplicationMutation,
useGetEligibleExamsQuery,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useRetakeExamMutation,

View File

@@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
FamilyKind,
FieldCondition,
FormFieldConfig,
FormSectionConfig,
LicenseApplication,
@@ -68,6 +69,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
REVIEW_REPORTED: 'Review Reported',
INSPECTION_COMPLETED: 'Inspection Completed',
INSPECTION_REPORTED: 'Inspection Reported',
INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
@@ -99,6 +101,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
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',
@@ -134,6 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
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,
@@ -187,6 +193,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
'CERTIFICATE_OF_PROFICIENCY',
'VESSEL_REGISTRATION',
'VESSEL_OWNERSHIP_TRANSFER',
'ENDORSEMENT_SEAFARER',
'ENDORSEMENT_COC',
'ENDORSEMENT_GOC',
];
@@ -198,6 +205,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',
@@ -381,6 +389,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.',
};
@@ -565,15 +577,33 @@ 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;
notEquals?: unknown;
in?: (string | number)[];
isSet?: boolean;
/** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
anyOf?: ConditionLike[];
}
export function conditionHolds(
condition:
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
| undefined
| null,
condition: FieldCondition | undefined | null,
formData: Record<string, Record<string, unknown>>,
): boolean {
if (!condition?.field) return true;
if (!condition) return true;
if (condition.anyOf) {
return condition.anyOf.some((sub) => conditionHolds(sub, formData));
}
if (!condition.field) return true;
const value = condition.field
.split('.')
.reduce<unknown>(

View File

@@ -33,6 +33,10 @@ export type LicenseStatus =
| "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"
@@ -71,11 +75,18 @@ export type FormFieldType =
| "TIN";
export interface FieldCondition {
field: string;
/** Omitted when `anyOf` is used instead — see below. */
field?: string;
equals?: string | number | boolean;
notEquals?: string | number | boolean;
in?: (string | number)[];
isSet?: boolean;
/**
* 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[];
}
export interface FormFieldConfig {
@@ -578,19 +589,50 @@ 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;
}
/** A certificate design authored in the backoffice. */
/** An STCW seafarer department (Deck, Engine, Catering), backoffice-managed. */
export interface Department {
id: string;
/** Matches the ESeafarerDepartment value stored elsewhere, e.g. "DECK". */
code: string;
name: Bilingual;
sortOrder: number;
isActive: boolean;
}
export type RankCertificateCategory = "COC" | "COP";
/** One rung of a CoC/CoP ladder for a department. */
export interface Rank {
id: string;
departmentId: string;
certificateCategory: RankCertificateCategory;
/** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */
key: string;
name: Bilingual;
/** Rung position within its department+category ladder. 0 is the entry rank. */
ladderOrder: number;
sortOrder: number;
isActive: boolean;
}
export interface LicenseTemplate {
id: string;
licenseTypeId: string;
/** Scopes this design to one rank's certificate. Null = the type's default. */
rankId?: string | null;
name: string;
version: number;
hbsSource: string;
@@ -617,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> {
@@ -703,3 +747,13 @@ export interface IssuedLicense {
verificationCode: string;
certificateFileKey: string | null;
}
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;
title: { en: string; am: string };
date: string;
venue: string;
status: string;
certification?: { id: string; name: { en: string; am: string }; rankKey: string | null };
}

View File

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

View File

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