import { resolveTokenFromStorage } from '../../session'; import type { Bilingual, FormSectionConfig, LicenseApplication, LicenseStatus, ValidationIssue, } from './licensing.types'; const BASE_API_URL = (import.meta as { env?: Record }).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 = { 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 = { 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 = { 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 | 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). return (language === 'am' ? value.am : value.en) || 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 })?.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 = { 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 })?.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>, 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(); 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; /** * 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>, 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>, ): boolean { if (!condition?.field) return true; const value = condition.field .split('.') .reduce( (acc, key) => acc && typeof acc === 'object' ? (acc as Record)[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; }