import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ActionIcon, Alert, Badge, Button, Card, Center, Container, Divider, Group, Loader, Modal, NumberInput, Paper, Stack, Stepper, Table, Text, TextInput, Title, } from '@mantine/core'; import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPlus, IconTrash, } from '@tabler/icons-react'; import { notifications } from '@mantine/notifications'; import { buildWizardSteps, conditionHolds, extractErrorMessage, extractValidationIssues, localized, validateSections, useAddStaffMutation, useCreateApplicationMutation, useGetApplicationQuery, useGetAttachmentsQuery, useGetLicenseTypeRequirementsQuery, usePatchSectionMutation, useRemoveStaffMutation, useResolveRemarkMutation, useResubmitApplicationMutation, useSubmitApplicationMutation, type FieldErrors, type ValidationIssue, } from '@ema-platform/api'; import { ModalFooter } from '@ema-platform/ui'; import { useCurrentProfile } from '@ema-platform/auth'; import { ConfigDrivenSection } from '../components/ConfigDrivenSection'; import { DocumentSlots } from '../components/DocumentSlots'; import { StaffEvidence } from '../components/StaffEvidence'; /** * The applicant wizard, rendered entirely from the license type's * configuration. The same page serves every license type — the route's * `typeCode` decides which configuration is loaded. */ export function LicenseApplicationPage() { const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams(); const navigate = useNavigate(); const { data: config, isLoading: loadingConfig } = useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode }); const { profile } = useCurrentProfile(); const [createApplication] = useCreateApplicationMutation(); const [appId, setAppId] = useState(applicationId); // Create (or resume) the draft up front, so uploads have a real owner to // attach to and nothing is lost if the browser is closed mid-wizard. useEffect(() => { if (appId || !config) return; createApplication({ licenseType: typeCode }) .unwrap() .then((app) => setAppId(app.id)) .catch((err) => notifications.show({ color: 'red', title: 'Could not start application', message: extractErrorMessage(err), }), ); }, [appId, config, createApplication, typeCode]); const { data: detail, refetch } = useGetApplicationQuery(appId as string, { skip: !appId, }); const { data: attachments = [], refetch: refetchAttachments } = useGetAttachmentsQuery( { ownerType: 'APPLICATION', ownerId: appId as string }, { skip: !appId }, ); const [patchSection] = usePatchSectionMutation(); const [submitApplication, { isLoading: submitting }] = useSubmitApplicationMutation(); const [resubmitApplication, { isLoading: resubmitting }] = useResubmitApplicationMutation(); const [resolveRemark] = useResolveRemarkMutation(); const [addStaff] = useAddStaffMutation(); const [removeStaff] = useRemoveStaffMutation(); const [active, setActive] = useState(0); const [draft, setDraft] = useState>>({}); const [issues, setIssues] = useState([]); const [fieldErrors, setFieldErrors] = useState({}); const [staffModal, setStaffModal] = useState(null); const [newStaff, setNewStaff] = useState({ fullName: '', position: '', yearsOfExperience: 0 }); // Seed local edits from the server copy once it arrives. useEffect(() => { if (detail?.application?.formData) setDraft(detail.application.formData); }, [detail?.application?.id, detail?.application?.adjustmentRound]); // Nationality is already on file from the profile's Address tab — carry it // into whichever section the form config puts a `nationality` field in, // rather than asking again. Only fills a blank; a value already on the // draft (the applicant's own edit, or one the server saved) is left alone. useEffect(() => { const nationality = profile?.address?.nationality; if (!nationality || !config) return; const section = config.licenseType.formSchema.sections.find((s) => s.fields.some((f) => f.key === 'nationality'), ); if (!section) return; setDraft((prev) => prev[section.key]?.nationality ? prev : { ...prev, [section.key]: { ...prev[section.key], nationality } }, ); }, [profile?.address?.nationality, config]); const application = detail?.application; const isAdjusting = application?.status === 'RESUBMIT_REQUIRED'; const openRemarks = detail?.openRemarks ?? []; const flaggedSections = useMemo( () => Object.fromEntries( openRemarks.filter((r) => r.targetType === 'FORM_SECTION').map((r) => [r.targetKey, r.remark]), ), [openRemarks], ); const flaggedDocuments = useMemo( () => Object.fromEntries( openRemarks.filter((r) => r.targetType === 'DOCUMENT').map((r) => [r.targetKey, r.remark]), ), [openRemarks], ); // Sections that share a group collapse onto one step, so the stepper stays // short instead of showing a page per section. const steps = useMemo( () => buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, { hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0, }), [config, draft], ); const sections = useMemo( () => steps.flatMap((step) => step.sections), [steps], ); if (loadingConfig || !config || !appId || !application) { return (
); } const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status); async function saveSection(sectionKey: string) { // During an adjustment round only flagged sections are editable, so don't // even attempt a write the server would reject. if (isAdjusting && !flaggedSections[sectionKey]) return; try { await patchSection({ id: appId as string, sectionKey, values: draft[sectionKey] ?? {}, }).unwrap(); } catch (err) { notifications.show({ color: 'red', title: 'Could not save', message: extractErrorMessage(err), }); } } async function handleSubmit() { setIssues([]); if (!readOnly && currentStep?.sections?.length) { const errors = validateSections(currentStep.sections, draft); setFieldErrors(errors); if (Object.keys(errors).length) { notifications.show({ color: 'red', title: 'Incomplete', message: 'Complete the highlighted fields before submitting.', }); return; } } for (const section of sections) await saveSection(section.key); try { if (isAdjusting) { for (const remark of openRemarks) { await resolveRemark({ id: appId as string, remarkId: remark.id }).unwrap(); } await resubmitApplication(appId as string).unwrap(); notifications.show({ color: 'teal', title: 'Resubmitted', message: 'Your corrections were sent back to the reviewing officer.', }); } else { await submitApplication(appId as string).unwrap(); notifications.show({ color: 'teal', title: 'Application submitted', message: 'You will be notified as it progresses.', }); } navigate('/licensing/applications'); } catch (err) { const found = extractValidationIssues(err); setIssues(found); notifications.show({ color: 'red', title: 'Application incomplete', message: found.length ? `${found.length} item(s) still need attention.` : extractErrorMessage(err), }); } } const currentStep = steps[active]; /** * Checks the current step before moving on. * * The server rejects an incomplete application anyway, but only at submit — * by then the applicant has walked through every step and has to hunt for * what was missing. Validating per step points at the field directly. */ async function validateCurrentStep(): Promise { // The wizard does not render until the configuration has loaded, but this // is declared above that guard, so narrow it here too. if (!currentStep || !config) return true; if (currentStep.kind === 'sections') { const errors = validateSections(currentStep.sections, draft); setFieldErrors(errors); const count = Object.keys(errors).length; if (count > 0) { notifications.show({ color: 'red', title: 'Incomplete', message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`, }); return false; } return true; } if (currentStep.kind === 'staff') { const missing = config.staffRoleRequirements .filter( (role) => (detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey).length < role.minCount, ) .map((role) => `${localized(role.name)} (${role.minCount} required)`); if (missing.length) { notifications.show({ color: 'red', title: 'Staff incomplete', message: `Still needed: ${missing.join(', ')}.`, }); return false; } return true; } if (currentStep.kind === 'documents') { const supplied = new Set( attachments.filter((a) => a.files?.length).map((a) => a.documentKey), ); const missing = config.documentRequirements .filter( (req) => req.mode === 'ALWAYS' || (req.mode === 'CONDITIONAL' && conditionHolds(req.conditionExpression, draft)), ) .filter((req) => !supplied.has(req.key)) .map((req) => localized(req.name)); if (missing.length) { notifications.show({ color: 'red', title: 'Documents missing', message: `Upload: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ` and ${missing.length - 3} more` : ''}.`, }); return false; } return true; } return true; } async function handleContinue() { // A locked step during an adjustment round has nothing to validate. if (!readOnly && !(await validateCurrentStep())) return; if (currentStep?.kind === 'sections') { for (const section of currentStep.sections) await saveSection(section.key); } setFieldErrors({}); setActive((s) => Math.min(steps.length - 1, s + 1)); } /** Going back is always allowed; going forward validates each step passed. */ async function goToStep(target: number) { if (target <= active) { setActive(target); return; } if (!readOnly && !(await validateCurrentStep())) return; if (currentStep?.kind === 'sections') { for (const section of currentStep.sections) await saveSection(section.key); } setFieldErrors({}); setActive(active + 1); } return (
{localized(config.licenseType.name)} {application.applicationNumber} ·{' '} {application.status.replace(/_/g, ' ')}
Fee: {config.fee ?? '—'} {config.feeCurrency}
{isAdjusting && ( } title="Corrections requested" mb="md" > {openRemarks.map((remark) => ( {remark.targetKey}: {remark.remark} ))} Only the items listed above can be changed. )} {issues.length > 0 && ( } title="Still missing" mb="md"> {issues.map((issue, i) => ( • {issue.message} ))} )} {steps.map((step) => ( ))} {currentStep?.kind === 'sections' && ( {currentStep.sections.map((section, index) => { const locked = isAdjusting && !flaggedSections[section.key]; return (
{index > 0 && } {localized(section.title)} {locked && ( } mb="md"> This section was accepted and is locked for this round. )} { setDraft((prev) => ({ ...prev, [section.key]: { ...(prev[section.key] ?? {}), [key]: value }, })); // Clear the error as soon as the applicant addresses it. setFieldErrors((prev) => { const next = { ...prev }; delete next[`${section.key}.${key}`]; return next; }); }} />
); })}
)} {currentStep?.kind === 'staff' && ( {config.staffRoleRequirements.map((role) => { const members = (detail?.staff ?? []).filter((s) => s.roleKey === role.roleKey); return (
{localized(role.name)} {members.length} of {role.minCount} required {role.requiredEvidence.length > 0 && ` · each needs ${role.requiredEvidence .filter((e) => e.mandatory) .map((e) => localized(e.label)) .join(', ')}`}
{members.length >= role.minCount && ( }> complete )} {!readOnly && ( )}
{members.map((member) => (
{member.fullName} {member.position ?? '—'} {member.yearsOfExperience ? ` · ${member.yearsOfExperience} yrs` : ''}
{!readOnly && ( { await removeStaff({ id: appId, staffId: member.id }); refetch(); }} > )}
))}
); })}
)} {currentStep?.kind === 'documents' && ( { refetchAttachments(); refetch(); }} /> )} {currentStep?.kind === 'review' && ( {currentStep.sections.map((section) => (
{localized(section.title)} { setDraft((prev) => ({ ...prev, [section.key]: { ...(prev[section.key] ?? {}), [key]: value }, })); setFieldErrors((prev) => { const next = { ...prev }; delete next[`${section.key}.${key}`]; return next; }); }} />
))} Review {sections.map((section) => (
{localized(section.title)} {(section.fields ?? []) .filter((f) => conditionHolds(f.showWhen, draft)) .map((field) => ( {localized(field.label)} {String(draft[section.key]?.[field.key] ?? '—')} ))}
))}
)} {active < steps.length - 1 ? ( ) : ( )}
setStaffModal(null)} title="Add staff member" > setNewStaff({ ...newStaff, fullName: e.currentTarget.value })} /> setNewStaff({ ...newStaff, position: e.currentTarget.value })} /> setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })} min={0} />
); } export default LicenseApplicationPage;