Files
emaui/libs/api/src/lib/features/licensing/licensing.helpers.ts

437 lines
16 KiB
TypeScript

import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
FormSectionConfig,
LicenseApplication,
LicenseStatus,
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/**
* Uploads a document straight to the API.
*
* Files go up the moment they're picked rather than being held until submit —
* the application already exists as a draft, so there is a real owner to
* attach them to, and nothing can be lost if the browser closes mid-wizard.
*/
export async function uploadDocument(params: {
ownerType:
| 'APPLICATION'
| 'APPLICATION_STAFF'
| 'INSPECTION'
| 'LICENSE'
| 'SEA_SERVICE_RECORD'
| 'MEDICAL_CERTIFICATE';
ownerId: string;
documentKey: string;
file: File;
title?: string;
validFrom?: string;
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);
const token = resolveTokenFromStorage();
const res = await fetch(`${BASE_API_URL}/attachments`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (res.ok) return { ok: true };
const body = await res.json().catch(() => null);
return { ok: false, error: body?.message ?? `Upload failed (${res.status})` };
}
/** 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',
ELIGIBILITY_APPROVED: 'Eligible to Sit',
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
EXAM_PAID: 'Awaiting Exam Date',
EXAM_SCHEDULED: 'Exam Scheduled',
EXAM_PASSED: 'Exam Passed',
EXAM_FAILED: 'Exam Not Passed',
};
/** 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',
ELIGIBILITY_APPROVED: 'teal',
EXAM_PAYMENT_PENDING: 'yellow',
EXAM_PAID: 'lime',
EXAM_SCHEDULED: 'cyan',
EXAM_PASSED: 'teal',
// Orange, not red: a failure is recoverable here — the candidate resits.
EXAM_FAILED: 'orange',
};
/**
* Roughly how far through the lifecycle a status sits, as a percentage.
*
* Used only to draw a progress bar. The two terminal outcomes both read as
* finished — a rejection is the end of the road, not 60% of the way somewhere.
*/
export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
DRAFT: 5,
SUBMITTED: 15,
UNDER_REVIEW: 30,
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
INSPECTION_COMPLETED: 65,
APPROVED: 75,
// Parked, so it keeps the progress of wherever it was held from.
ON_HOLD: 45,
PAYMENT_PENDING: 80,
PAID: 88,
PAYMENT_CONFIRMED: 94,
CERTIFICATE_ISSUED: 100,
COMPLETED: 100,
REJECTED: 100,
// The exam leg sits between approval and the certificate fee, so these
// interleave with PAYMENT_PENDING (80) rather than running past it.
ELIGIBILITY_APPROVED: 60,
EXAM_PAYMENT_PENDING: 64,
EXAM_PAID: 68,
EXAM_SCHEDULED: 72,
EXAM_PASSED: 78,
// A resit returns to the fee step, so this is not further along than a pass.
EXAM_FAILED: 64,
};
/** Statuses where nothing moves until the applicant does something. */
export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
'DRAFT',
'RESUBMIT_REQUIRED',
'PAYMENT_PENDING',
// Both wait on the candidate: one to pay for a sitting, one to decide to
// sit again after a failure.
'EXAM_PAYMENT_PENDING',
'EXAM_FAILED',
];
/** Statuses that are finished, whichever way they went. */
export const TERMINAL_STATUSES: LicenseStatus[] = [
'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',
];
/** 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)) {
return app.companyName ?? 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 '';
// `||` not `??`: an empty Amharic string is "not translated", not a value —
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
//
// Keyed by the active language rather than an en/am ternary, so a locale the
// backend stores but the UI does not yet offer a switcher for (`om`, `so` on
// location names) still resolves once it does. English then Amharic remain
// the fallbacks, in that order.
const active = value[language as keyof Bilingual];
return active || value.en || value.am || '';
}
/**
* Pulls the per-field issue list out of a failed submit so the wizard can say
* exactly what is missing instead of a generic error.
*/
export function extractValidationIssues(error: unknown): ValidationIssue[] {
const data = (error as { data?: Record<string, unknown> })?.data;
if (!data) return [];
const issues = data['issues'];
return Array.isArray(issues) ? (issues as ValidationIssue[]) : [];
}
/**
* Server error keys rendered for a human.
*
* The API answers with stable machine keys; showing those raw (e.g.
* "capital_verification_required") tells an officer nothing about what to do.
*/
const ERROR_MESSAGES: Record<string, string> = {
capital_verification_required:
'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.',
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.',
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.',
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.',
inspection_not_required_use_final_approve:
'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.',
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
const data = (error as { data?: Record<string, unknown> })?.data;
const raw = data?.['message'];
// The filter passes structured errors through as `{ message: { message } }`.
const key =
typeof raw === 'string'
? raw
: 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(', ');
return fallback;
}
/** One wizard step: either a group of form sections, or a fixed stage. */
export interface WizardStep {
key: string;
label: string;
kind: 'sections' | 'staff' | 'documents' | 'review';
sections: FormSectionConfig[];
}
/**
* Turns the configured sections into wizard steps.
*
* Sections that share a `group` collapse onto one step, which is what keeps
* the stepper short. Anything ungrouped keeps a step of its own, so a licence
* type that has not been grouped still behaves exactly as before.
*/
/** "identitySummary" -> "Identity Summary". Last resort for an untitled group. */
function humanise(key: string): string {
return key
.replace(/[_-]+/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
.replace(/^./, (c) => c.toUpperCase());
}
export function buildWizardSteps(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
options?: {
/**
* Whether this licence type has staff-role requirements at all. Types
* without any (seafarer registration) skip the Staff step entirely
* instead of showing an empty page.
*/
hasStaff?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
},
): WizardStep[] {
const visible = [...sections]
.filter((section) => conditionHolds(section.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const steps: WizardStep[] = [];
const byGroup = new Map<string, WizardStep>();
for (const section of visible) {
const group = section.group?.trim();
if (!group) {
steps.push({
key: `section:${section.key}`,
label: localized(section.title, options?.language),
kind: 'sections',
sections: [section],
});
continue;
}
const existing = byGroup.get(group);
if (existing) {
existing.sections.push(section);
continue;
}
const step: WizardStep = {
key: `group:${group}`,
// A group is a config key, not a caption — showing it raw put
// "identitySummary" and "applicant" in front of applicants. The first
// section's own title is the readable name for the step it opens; the
// humanised key is the fallback when a section carries no title.
label: localized(section.title, options?.language) || humanise(group),
kind: 'sections',
sections: [section],
};
byGroup.set(group, step);
steps.push(step);
}
// Honour explicit group ordering where given, keeping config order otherwise.
const orderOf = (step: WizardStep) =>
Math.min(...step.sections.map((s) => s.groupOrder ?? s.sortOrder ?? 0));
steps.sort((a, b) => orderOf(a) - orderOf(b));
// "Review" is a reserved group: those sections render on the final step
// 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');
const reviewSections = steps.filter(isReviewGroup).flatMap((s) => s.sections);
const formSteps = steps.filter((step) => !isReviewGroup(step));
return [
...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 },
];
}
/** `${sectionKey}.${fieldKey}` → message, for inline display under a field. */
export type FieldErrors = Record<string, string>;
/**
* Checks the required fields of the given sections against the answers,
* skipping anything a condition currently hides. Used to stop the wizard
* advancing past an incomplete step rather than failing only at submit.
*/
export function validateSections(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
language = 'en',
): FieldErrors {
const errors: FieldErrors = {};
for (const section of sections) {
if (!conditionHolds(section.showWhen, formData)) continue;
const values = formData[section.key] ?? {};
for (const field of section.fields ?? []) {
if (!conditionHolds(field.showWhen, formData)) continue;
// Read-only values are supplied by the server, not the applicant.
if (field.readOnly && field.source) continue;
const value = values[field.key];
const empty =
value === undefined ||
value === null ||
(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'
? `${localized(field.label, language)} must be accepted`
: `${localized(field.label, language)} is required`;
continue;
}
if (empty) continue;
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
if (field.min !== undefined && numeric < field.min) {
errors[`${section.key}.${field.key}`] =
`Must be at least ${field.min.toLocaleString()}`;
} else if (field.max !== undefined && numeric > field.max) {
errors[`${section.key}.${field.key}`] =
`Must not exceed ${field.max.toLocaleString()}`;
}
}
}
}
return errors;
}
/** Evaluates a config condition against the current form answers. */
export function conditionHolds(
condition:
| { 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('.')
.reduce<unknown>(
(acc, key) =>
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;
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);
return true;
}