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:3001/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", }; /** 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", }; /** * 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", ]; /** 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. */ 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}`, label: 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; }