Merge branch 'feature/exam-attempt-domain' of github.com:Tria-plc/emaui into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-17 11:51:33 +03:00
8 changed files with 210 additions and 250 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:3001/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

@@ -1,15 +1,16 @@
import { resolveTokenFromStorage } from '../../session';
import { resolveTokenFromStorage } from "../../session";
import type {
Bilingual,
FormSectionConfig,
LicenseApplication,
LicenseStatus,
ValidationIssue,
} from './licensing.types';
} from "./licensing.types";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
/**
* Uploads a document straight to the API.
@@ -20,12 +21,12 @@ const BASE_API_URL =
*/
export async function uploadDocument(params: {
ownerType:
| 'APPLICATION'
| 'APPLICATION_STAFF'
| 'INSPECTION'
| 'LICENSE'
| 'SEA_SERVICE_RECORD'
| 'MEDICAL_CERTIFICATE';
| "APPLICATION"
| "APPLICATION_STAFF"
| "INSPECTION"
| "LICENSE"
| "SEA_SERVICE_RECORD"
| "MEDICAL_CERTIFICATE";
ownerId: string;
documentKey: string;
file: File;
@@ -34,17 +35,17 @@ export async function uploadDocument(params: {
validTo?: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const form = new FormData();
form.append('ownerType', params.ownerType);
form.append('ownerId', params.ownerId);
form.append('documentKey', params.documentKey);
if (params.title) form.append('title', params.title);
if (params.validFrom) form.append('validFrom', params.validFrom);
if (params.validTo) form.append('validTo', params.validTo);
form.append('files', params.file, params.file.name);
form.append("ownerType", params.ownerType);
form.append("ownerId", params.ownerId);
form.append("documentKey", params.documentKey);
if (params.title) form.append("title", params.title);
if (params.validFrom) form.append("validFrom", params.validFrom);
if (params.validTo) form.append("validTo", params.validTo);
form.append("files", params.file, params.file.name);
const token = resolveTokenFromStorage();
const res = await fetch(`${BASE_API_URL}/attachments`, {
method: 'POST',
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
@@ -55,40 +56,40 @@ export async function uploadDocument(params: {
/** Human label for a status, in the vocabulary the user stories use. */
export const STATUS_LABELS: Record<LicenseStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
INSPECTION_COMPLETED: 'Inspection Completed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing Certificate',
CERTIFICATE_ISSUED: 'Certificate Issued',
COMPLETED: 'Completed',
DRAFT: "Draft",
SUBMITTED: "Submitted",
UNDER_REVIEW: "Under Review",
UNDER_EVALUATION: "Under Evaluation",
RESUBMIT_REQUIRED: "Resubmit Required",
INSPECTION_PENDING: "Inspection Pending",
INSPECTION_COMPLETED: "Inspection Completed",
APPROVED: "Approved",
REJECTED: "Rejected",
ON_HOLD: "On Hold",
PAYMENT_PENDING: "Payment Pending",
PAID: "Paid",
PAYMENT_CONFIRMED: "Preparing Certificate",
CERTIFICATE_ISSUED: "Certificate Issued",
COMPLETED: "Completed",
};
/** Mantine colour per status — green progresses, orange needs the applicant. */
export const STATUS_COLORS: Record<LicenseStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
INSPECTION_COMPLETED: 'cyan',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'gray',
PAYMENT_PENDING: 'yellow',
PAID: 'lime',
PAYMENT_CONFIRMED: 'teal',
CERTIFICATE_ISSUED: 'green',
COMPLETED: 'green',
DRAFT: "gray",
SUBMITTED: "blue",
UNDER_REVIEW: "indigo",
UNDER_EVALUATION: "indigo",
RESUBMIT_REQUIRED: "orange",
INSPECTION_PENDING: "cyan",
INSPECTION_COMPLETED: "cyan",
APPROVED: "teal",
REJECTED: "red",
ON_HOLD: "gray",
PAYMENT_PENDING: "yellow",
PAID: "lime",
PAYMENT_CONFIRMED: "teal",
CERTIFICATE_ISSUED: "green",
COMPLETED: "green",
};
/**
@@ -118,46 +119,59 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
/** Statuses where nothing moves until the applicant does something. */
export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
'DRAFT',
'RESUBMIT_REQUIRED',
'PAYMENT_PENDING',
"DRAFT",
"RESUBMIT_REQUIRED",
"PAYMENT_PENDING",
];
/** Statuses that are finished, whichever way they went. */
export const TERMINAL_STATUSES: LicenseStatus[] = [
'CERTIFICATE_ISSUED',
'COMPLETED',
'REJECTED',
"CERTIFICATE_ISSUED",
"COMPLETED",
"REJECTED",
];
/** Licence types with no `companyName` — these are filed by a person, not a
* business, so display falls back to the applicant name captured in the form. */
export const APPLICANT_NAME_TYPE_KEYS = [
'SEAFARER_REGISTRATION',
'CERTIFICATE_OF_COMPETENCY',
'CERTIFICATE_OF_PROFICIENCY',
'VESSEL_REGISTRATION',
'VESSEL_OWNERSHIP_TRANSFER',
'ENDORSEMENT_COC',
'ENDORSEMENT_GOC',
"SEAFARER_REGISTRATION",
"CERTIFICATE_OF_COMPETENCY",
"CERTIFICATE_OF_PROFICIENCY",
"VESSEL_REGISTRATION",
"VESSEL_OWNERSHIP_TRANSFER",
"ENDORSEMENT_COC",
"ENDORSEMENT_GOC",
];
/** Company name, or applicant name for licence types that have no company. */
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) {
export function applicantOrCompanyName(
app: LicenseApplication,
): string | undefined {
if (
!app.licenseType?.key ||
!APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)
) {
return app.companyName ?? undefined;
}
const applicantName = (app.formData?.account as Record<string, unknown> | undefined)
?.applicantName;
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
const applicantName = (
app.formData?.account as Record<string, unknown> | undefined
)?.applicantName;
return typeof applicantName === "string" && applicantName
? applicantName
: undefined;
}
/** Reads a bilingual value for the active language, falling back to English. */
export function localized(value: Bilingual | undefined, language = 'en'): string {
if (!value) return '';
export function localized(
value: Bilingual | undefined,
language = "en",
): string {
if (!value) return "";
// `||` not `??`: an empty Amharic string is "not translated", not a value —
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
return (language === 'am' ? value.am : value.en) || value.en || value.am || '';
return (
(language === "am" ? value.am : value.en) || value.en || value.am || ""
);
}
/**
@@ -167,7 +181,7 @@ export function localized(value: Bilingual | undefined, language = 'en'): string
export function extractValidationIssues(error: unknown): ValidationIssue[] {
const data = (error as { data?: Record<string, unknown> })?.data;
if (!data) return [];
const issues = data['issues'];
const issues = data["issues"];
return Array.isArray(issues) ? (issues as ValidationIssue[]) : [];
}
@@ -179,47 +193,54 @@ export function extractValidationIssues(error: unknown): ValidationIssue[] {
*/
const ERROR_MESSAGES: Record<string, string> = {
capital_verification_required:
'Record the verified capital before approving — it must be checked against the bank letter.',
"Record the verified capital before approving — it must be checked against the bank letter.",
capital_below_threshold:
'The verified capital is below the minimum required for this licence type.',
"The verified capital is below the minimum required for this licence type.",
inspection_required_before_approval:
'A physical inspection must be recorded before this application can be approved.',
application_already_claimed: 'Another officer has already claimed this application.',
"A physical inspection must be recorded before this application can be approved.",
application_already_claimed:
"Another officer has already claimed this application.",
assigned_to_another_officer:
'This application is assigned to another officer, so only they can act on it.',
not_application_owner: 'This application belongs to another applicant.',
application_incomplete: 'The application is missing required information.',
unresolved_remarks: 'Every requested correction must be addressed first.',
adjustment_items_required: 'Flag at least one item before requesting an adjustment.',
remark_text_required: 'Each flagged item needs a remark explaining what to fix.',
rejection_reason_required: 'A rejection reason is required.',
"This application is assigned to another officer, so only they can act on it.",
not_application_owner: "This application belongs to another applicant.",
application_incomplete: "The application is missing required information.",
unresolved_remarks: "Every requested correction must be addressed first.",
adjustment_items_required:
"Flag at least one item before requesting an adjustment.",
remark_text_required:
"Each flagged item needs a remark explaining what to fix.",
rejection_reason_required: "A rejection reason is required.",
section_not_flagged_for_adjustment:
'Only the sections the officer flagged can be changed in this round.',
application_not_editable: 'This application can no longer be edited.',
invalid_transition: 'That action is not available at this stage.',
"Only the sections the officer flagged can be changed in this round.",
application_not_editable: "This application can no longer be edited.",
invalid_transition: "That action is not available at this stage.",
inspection_not_required_use_final_approve:
'This licence type needs no inspection — approve it directly.',
"This licence type needs no inspection — approve it directly.",
application_not_awaiting_inspection:
'This application is not waiting for an inspection.',
inspection_already_completed: 'This inspection has already been recorded.',
license_type_inactive: 'This licence type is not currently accepting applications.',
"This application is not waiting for an inspection.",
inspection_already_completed: "This inspection has already been recorded.",
license_type_inactive:
"This licence type is not currently accepting applications.",
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
export function extractErrorMessage(
error: unknown,
fallback = "Something went wrong",
): string {
const data = (error as { data?: Record<string, unknown> })?.data;
const raw = data?.['message'];
const raw = data?.["message"];
// The filter passes structured errors through as `{ message: { message } }`.
const key =
typeof raw === 'string'
typeof raw === "string"
? raw
: typeof (raw as { message?: unknown })?.message === 'string'
? ((raw as { message: string }).message)
: typeof (raw as { message?: unknown })?.message === "string"
? (raw as { message: string }).message
: undefined;
if (key && ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
if (typeof raw === 'string') return raw;
if (Array.isArray(raw)) return raw.join(', ');
if (typeof raw === "string") return raw;
if (Array.isArray(raw)) return raw.join(", ");
return fallback;
}
@@ -227,7 +248,7 @@ export function extractErrorMessage(error: unknown, fallback = 'Something went w
export interface WizardStep {
key: string;
label: string;
kind: 'sections' | 'staff' | 'documents' | 'review';
kind: "sections" | "staff" | "documents" | "review";
sections: FormSectionConfig[];
}
@@ -266,7 +287,7 @@ export function buildWizardSteps(
steps.push({
key: `section:${section.key}`,
label: localized(section.title, options?.language),
kind: 'sections',
kind: "sections",
sections: [section],
});
continue;
@@ -279,7 +300,7 @@ export function buildWizardSteps(
const step: WizardStep = {
key: `group:${group}`,
label: group,
kind: 'sections',
kind: "sections",
sections: [section],
};
byGroup.set(group, step);
@@ -295,7 +316,7 @@ export function buildWizardSteps(
// beside the summary rather than earning a step of their own. It is what
// keeps a one-checkbox declaration from costing a whole page.
const isReviewGroup = (step: WizardStep) =>
step.sections.some((s) => s.group?.trim().toLowerCase() === 'review');
step.sections.some((s) => s.group?.trim().toLowerCase() === "review");
const reviewSections = steps.filter(isReviewGroup).flatMap((s) => s.sections);
const formSteps = steps.filter((step) => !isReviewGroup(step));
@@ -303,9 +324,21 @@ export function buildWizardSteps(
...formSteps,
...(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 },
: [
{
key: "staff",
label: "Staff",
kind: "staff",
sections: [],
} as WizardStep,
]),
{ key: "documents", label: "Documents", kind: "documents", sections: [] },
{
key: "review",
label: "Review",
kind: "review",
sections: reviewSections,
},
];
}
@@ -320,7 +353,7 @@ export type FieldErrors = Record<string, string>;
export function validateSections(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
language = 'en',
language = "en",
): FieldErrors {
const errors: FieldErrors = {};
@@ -337,13 +370,13 @@ export function validateSections(
const empty =
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim() === '') ||
(typeof value === 'boolean' && value === false) ||
(typeof value === "string" && value.trim() === "") ||
(typeof value === "boolean" && value === false) ||
(Array.isArray(value) && value.length === 0);
if (field.required && empty) {
errors[`${section.key}.${field.key}`] =
field.type === 'BOOLEAN'
field.type === "BOOLEAN"
? `${localized(field.label, language)} must be accepted`
: `${localized(field.label, language)} is required`;
continue;
@@ -368,23 +401,33 @@ export function validateSections(
/** Evaluates a config condition against the current form answers. */
export function conditionHolds(
condition:
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
| {
field: string;
equals?: unknown;
notEquals?: unknown;
in?: (string | number)[];
isSet?: boolean;
}
| undefined
| null,
formData: Record<string, Record<string, unknown>>,
): boolean {
if (!condition?.field) return true;
const value = condition.field
.split('.')
.split(".")
.reduce<unknown>(
(acc, key) =>
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined,
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[key]
: undefined,
formData as unknown,
);
const isEmpty = value === undefined || value === null || value === '';
if (condition.isSet !== undefined) return condition.isSet ? !isEmpty : isEmpty;
const isEmpty = value === undefined || value === null || value === "";
if (condition.isSet !== undefined)
return condition.isSet ? !isEmpty : isEmpty;
if (condition.equals !== undefined) return value === condition.equals;
if (condition.notEquals !== undefined) return value !== condition.notEquals;
if (condition.in !== undefined) return condition.in.includes(value as string | number);
if (condition.in !== undefined)
return condition.in.includes(value as string | number);
return true;
}