mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 14:50:58 +00:00
621 lines
23 KiB
TypeScript
621 lines
23 KiB
TypeScript
import { isValidPhoneNumber } from 'libphonenumber-js';
|
|
import { resolveTokenFromStorage } from '../../session';
|
|
import type {
|
|
Bilingual,
|
|
FamilyKind,
|
|
FieldCondition,
|
|
FormFieldConfig,
|
|
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'
|
|
| 'SEAFARER_REGISTRATION';
|
|
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',
|
|
REVIEW_REPORTED: 'Review Reported',
|
|
INSPECTION_COMPLETED: 'Inspection Completed',
|
|
INSPECTION_REPORTED: 'Inspection Reported',
|
|
INSPECTION_FAILED: 'Inspection Failed',
|
|
APPROVED: 'Approved',
|
|
REJECTED: 'Rejected',
|
|
ON_HOLD: 'On Hold',
|
|
PAYMENT_PENDING: 'Payment Pending',
|
|
PAID: 'Paid',
|
|
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
|
SCHEDULED: 'Pickup Scheduled',
|
|
CERTIFICATE_ISSUED: 'Certificate Issued',
|
|
COMPLETED: 'Completed',
|
|
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
|
|
ELIGIBILITY_PAID: 'Eligibility Under Review',
|
|
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',
|
|
// 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',
|
|
PAYMENT_PENDING: 'yellow',
|
|
PAID: 'lime',
|
|
PAYMENT_CONFIRMED: 'teal',
|
|
SCHEDULED: 'cyan',
|
|
CERTIFICATE_ISSUED: 'green',
|
|
COMPLETED: 'green',
|
|
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
|
|
ELIGIBILITY_PAID: 'lime',
|
|
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,
|
|
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,
|
|
PAYMENT_PENDING: 80,
|
|
PAID: 88,
|
|
PAYMENT_CONFIRMED: 94,
|
|
SCHEDULED: 97,
|
|
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_PAYMENT_PENDING: 52,
|
|
ELIGIBILITY_PAID: 56,
|
|
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',
|
|
// Due the moment an examined application is submitted, before any officer
|
|
// looks at it.
|
|
'ELIGIBILITY_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',
|
|
// Passed: the certificate fee falls due, and only the candidate can pay it.
|
|
'EXAM_PASSED',
|
|
];
|
|
|
|
/** 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_SEAFARER',
|
|
'ENDORSEMENT_COC',
|
|
'ENDORSEMENT_GOC',
|
|
];
|
|
|
|
const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
|
|
SEAFARER_REGISTRATION: 'DOCUMENT',
|
|
SEAMAN_BOOK: 'DOCUMENT',
|
|
VESSEL_REGISTRATION: 'DOCUMENT',
|
|
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',
|
|
SHIPPING_AGENT: 'LOGISTICS_LICENSE',
|
|
COMBINED_SA_FF: 'LOGISTICS_LICENSE',
|
|
MULTIMODAL_TRANSPORT_OPERATOR: 'LOGISTICS_LICENSE',
|
|
JOINT_INVESTOR: 'LOGISTICS_LICENSE',
|
|
};
|
|
|
|
/**
|
|
* `FamilyKind` for a type key, for data that predates `familyKind` being a
|
|
* real column on the server (cached responses from before the rollout, or
|
|
* anything that only ever had a bare key to go on). Every fresh response now
|
|
* carries `familyKind` directly from the server's own `family_kind` column —
|
|
* prefer that over calling this. Kept only as a fallback, and only for keys
|
|
* this map happens to know about; falls back further to `LOGISTICS_LICENSE`
|
|
* for anything else, reproducing today's "Licence ___" wording — the safe
|
|
* default for an unrecognised key.
|
|
*/
|
|
export function resolveFamilyKind(licenseTypeKey: string | undefined | null): FamilyKind {
|
|
return (licenseTypeKey && FAMILY_KIND_BY_KEY[licenseTypeKey]) || 'LOGISTICS_LICENSE';
|
|
}
|
|
|
|
export interface FamilyLabels {
|
|
/** e.g. "Certificate", "Document", "Licence". */
|
|
typeLabel: string;
|
|
/** e.g. "Certificate Number", "Document Number", "Licence Number". */
|
|
numberLabel: string;
|
|
/** e.g. "Certificate Holder", "Document Holder", "Licence Holder". */
|
|
holderLabel: string;
|
|
/** e.g. "Certificate Review", "Document Review", "Licence Review". */
|
|
reviewLabel: string;
|
|
}
|
|
|
|
const FAMILY_LABELS: Record<FamilyKind, FamilyLabels> = {
|
|
CERTIFICATE: {
|
|
typeLabel: 'Certificate',
|
|
numberLabel: 'Certificate Number',
|
|
holderLabel: 'Certificate Holder',
|
|
reviewLabel: 'Certificate Review',
|
|
},
|
|
DOCUMENT: {
|
|
typeLabel: 'Document',
|
|
numberLabel: 'Document Number',
|
|
holderLabel: 'Document Holder',
|
|
reviewLabel: 'Document Review',
|
|
},
|
|
LOGISTICS_LICENSE: {
|
|
typeLabel: 'Licence',
|
|
numberLabel: 'Licence Number',
|
|
holderLabel: 'Licence Holder',
|
|
reviewLabel: 'Licence Review',
|
|
},
|
|
};
|
|
|
|
export function familyLabels(familyKind: FamilyKind): FamilyLabels {
|
|
return FAMILY_LABELS[familyKind];
|
|
}
|
|
|
|
/**
|
|
* Company name, or applicant name for applications with no company.
|
|
*
|
|
* Branches on `familyKind` — the real data-model column — rather than a
|
|
* hand-maintained key list. A certificate or document is filed by a person,
|
|
* never a business, so it never has a `companyName` to show; a logistics
|
|
* licence always does. This is what used to be `APPLICANT_NAME_TYPE_KEYS`, a
|
|
* frontend array that had to be remembered and kept in sync by hand every
|
|
* time a new certificate/document type was added — it silently missed
|
|
* `SEAMAN_BOOK`/`BTC_BASIC_TRAINING`, which is why those rows rendered "—"
|
|
* instead of the applicant's name in the backoffice queue. `familyKind` is
|
|
* correct for every current and future type without a matching array update.
|
|
*/
|
|
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
|
|
if (app.familyKind === 'LOGISTICS_LICENSE') {
|
|
return app.companyName ?? undefined;
|
|
}
|
|
const applicantName = (app.formData?.account as Record<string, unknown> | undefined)
|
|
?.applicantName;
|
|
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
|
}
|
|
|
|
/**
|
|
* One form answer as a person should read it back.
|
|
*
|
|
* The stored value is not it: a SELECT holds the option's `value`, so an
|
|
* unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` —
|
|
* the codes the database wants, not the words that were chosen. Shared by the
|
|
* applicant's summary and the officer's review so the two never describe the
|
|
* same application differently.
|
|
*
|
|
* `showDate` is passed in rather than imported: date display is a hook
|
|
* (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function.
|
|
*/
|
|
export function displayFieldValue(
|
|
field: Pick<FormFieldConfig, 'type' | 'options'>,
|
|
raw: unknown,
|
|
opts: {
|
|
language?: string;
|
|
showDate?: (value: string) => string;
|
|
currency?: string;
|
|
} = {},
|
|
): string {
|
|
if (raw === null || raw === undefined || raw === '') return '';
|
|
const { language = 'en', showDate, currency } = opts;
|
|
|
|
switch (field.type) {
|
|
case 'BOOLEAN':
|
|
return raw ? 'Yes' : 'No';
|
|
case 'DATE':
|
|
return showDate?.(String(raw)) || String(raw);
|
|
case 'SELECT': {
|
|
const option = field.options?.find((o) => o.value === raw);
|
|
// Falls back to the stored value rather than blanking: an option removed
|
|
// from the config since this was filed still has to show what was chosen.
|
|
return option ? localized(option.label, language) : String(raw);
|
|
}
|
|
case 'MONEY': {
|
|
const amount = Number(raw);
|
|
return Number.isFinite(amount)
|
|
? `${amount.toLocaleString()} ${currency ?? ''}`.trim()
|
|
: String(raw);
|
|
}
|
|
default:
|
|
return String(raw);
|
|
}
|
|
}
|
|
|
|
/** 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.',
|
|
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.',
|
|
};
|
|
|
|
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;
|
|
|
|
// PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
|
|
// non-empty string that still has to be caught here.
|
|
if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
|
|
errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
|
|
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.
|
|
*
|
|
* 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: FieldCondition | undefined | null,
|
|
formData: Record<string, Record<string, unknown>>,
|
|
): boolean {
|
|
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>(
|
|
(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;
|
|
}
|