diff --git a/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx b/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx new file mode 100644 index 000000000..d071e8a42 --- /dev/null +++ b/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx @@ -0,0 +1,88 @@ +import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core"; +import { + conditionHolds, + type Attachment, + type FormSectionConfig, + type LicenseTypeRequirements, +} from "@ema-platform/api"; +import { DocumentSlots } from "./DocumentSlots"; + +interface Props { + /** Every form section (not just the "review" group) in wizard-step order. */ + sections: FormSectionConfig[]; + formData: Record>; + localized: (value: { en?: string; am?: string } | undefined) => string; + config: LicenseTypeRequirements; + attachments: Attachment[]; + applicationId: string; +} + +/** + * Read-only "what was filed" view for a submitted application: every + * answered section as a labelled table, then the uploaded documents. + * + * Shown instead of the wizard once there is nothing left to step through — + * the stepper is for filling a form in, not for re-reading one that is + * already someone else's decision to make. + */ +export function ApplicationSummary({ + sections, + formData, + localized, + config, + attachments, + applicationId, +}: Props) { + return ( + + + {sections.map((section) => ( +
+ + {localized(section.title)} + + + + {(section.fields ?? []) + .filter((f) => conditionHolds(f.showWhen, formData)) + .map((field) => ( + + + + {localized(field.label)} + + + + + {String(formData[section.key]?.[field.key] ?? "—")} + + + + ))} + +
+
+ ))} + +
+ + + Documents + + { + // Read-only here — nothing to react to, but DocumentSlots + // requires the callback. + }} + /> +
+
+
+ ); +} diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 3c95453c2..827fdbc8f 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, Alert, @@ -20,16 +20,16 @@ import { Text, TextInput, Title, -} from '@mantine/core'; +} from "@mantine/core"; import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPlus, IconTrash, -} from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; -import { useTranslation } from 'react-i18next'; +} from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; import { buildWizardSteps, conditionHolds, @@ -37,6 +37,8 @@ import { extractValidationIssues, useLocalized, validateSections, + STATUS_COLORS, + STATUS_LABELS, useAddStaffMutation, useCreateApplicationMutation, useGetApplicationQuery, @@ -52,17 +54,34 @@ import { type FormFieldConfig, type ValidationIssue, type Vessel, -} from '@ema-platform/api'; -import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui'; +} from "@ema-platform/api"; +import { getCountryCode, getCountryName, ModalFooter } from "@ema-platform/ui"; import { LICENSE_PERMISSIONS, PORTAL_PERMISSIONS, RequirePermission, useCurrentProfile, -} from '@ema-platform/auth'; -import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection'; -import { DocumentSlots } from '../components/DocumentSlots'; -import { StaffEvidence } from '../components/StaffEvidence'; +} from "@ema-platform/auth"; +import { ApplicationSummary } from "../components/ApplicationSummary"; +import { + ConfigDrivenSection, + fillFromVessel, +} from "../components/ConfigDrivenSection"; +import { DocumentSlots } from "../components/DocumentSlots"; +import { StaffEvidence } from "../components/StaffEvidence"; + +/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */ +function readSourcePath(context: Record, path: string): unknown { + return path + .split(".") + .reduce( + (acc, key) => + acc && typeof acc === "object" + ? (acc as Record)[key] + : undefined, + context, + ); +} /** * The applicant wizard, rendered entirely from the license type's @@ -70,7 +89,7 @@ import { StaffEvidence } from '../components/StaffEvidence'; * `typeCode` decides which configuration is loaded. */ export function LicenseApplicationPage() { - const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams(); + const { typeCode = "FREIGHT_FORWARDER", applicationId } = useParams(); const navigate = useNavigate(); const { i18n } = useTranslation(); const localized = useLocalized(); @@ -93,8 +112,8 @@ export function LicenseApplicationPage() { .then((app) => setAppId(app.id)) .catch((err) => notifications.show({ - color: 'red', - title: 'Could not start application', + color: "red", + title: "Could not start application", message: extractErrorMessage(err), }), ); @@ -105,23 +124,37 @@ export function LicenseApplicationPage() { }); const { data: attachments = [], refetch: refetchAttachments } = useGetAttachmentsQuery( - { ownerType: 'APPLICATION', ownerId: appId as string }, + { ownerType: "APPLICATION", ownerId: appId as string }, { skip: !appId }, ); const [patchSection] = usePatchSectionMutation(); - const [submitApplication, { isLoading: submitting }] = useSubmitApplicationMutation(); - const [resubmitApplication, { isLoading: resubmitting }] = useResubmitApplicationMutation(); + 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>>({}); + // A submitted (or otherwise non-draft) application opens straight to a + // read-only summary — status up top, everything the applicant filled below + // — instead of the entry step of a stepper there is nothing left to step + // through. RESUBMIT_REQUIRED is still summary-first, but "Edit details" + // drops into the ordinary wizard on the flagged sections. + const [viewingSummary, setViewingSummary] = useState(true); + 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 }); + const [newStaff, setNewStaff] = useState({ + fullName: "", + position: "", + yearsOfExperience: 0, + }); // Seed local edits from the server copy once it arrives. useEffect(() => { @@ -145,10 +178,18 @@ export function LicenseApplicationPage() { ) => { for (const section of config.licenseType.formSchema.sections) { // English-pinned: matched against English substrings below ('nationality', 'fayda'). - const field = section.fields.find((f) => matchField((f.label.en ?? '').toLowerCase(), f.key)); + const field = section.fields.find((f) => + matchField((f.label.en ?? "").toLowerCase(), f.key), + ); if (!field) continue; if (next[section.key]?.[field.key]) return; // already set — leave it - next = { ...next, [section.key]: { ...next[section.key], [field.key]: toFieldValue(field) } }; + next = { + ...next, + [section.key]: { + ...next[section.key], + [field.key]: toFieldValue(field), + }, + }; return; } }; @@ -158,15 +199,19 @@ export function LicenseApplicationPage() { // a CountrySelect (see ConfigDrivenSection), which takes alpha-2 // codes regardless of the backend's configured field type. fill( - (label, key) => key === 'nationality' || label.includes('nationality'), + (label, key) => + key === "nationality" || label.includes("nationality"), () => getCountryCode(address.nationality) ?? address.nationality, ); } - if (address.idType === 'NID' && address.idNumber) { + if (address.idType === "NID" && address.idNumber) { fill( (label, key) => - key === 'idNumber' || key === 'nationalId' || key === 'faydaNumber' || - label.includes('fayda') || label.includes('national id'), + key === "idNumber" || + key === "nationalId" || + key === "faydaNumber" || + label.includes("fayda") || + label.includes("national id"), () => address.idNumber, ); } @@ -175,23 +220,59 @@ export function LicenseApplicationPage() { // Also re-run after the server seed effect (above) replaces `draft` // wholesale — that effect can resolve after this one, wiping the // prefill back out since the server's own draft has none of this yet. - }, [profile?.address, config, detail?.application?.id, detail?.application?.formData]); + }, [ + profile?.address, + config, + detail?.application?.id, + detail?.application?.formData, + ]); + + // Generic fill for every field the config marks `readOnly` with a + // `source` — e.g. seafarer registration's read-only Identity Details step, + // which shows what's already on the profile instead of asking again. + // `readOnly` fields are never sent by the applicant and the server skips + // them at validation, so this is display-only; the profile itself is what + // an edit has to go through. + useEffect(() => { + if (!profile || !config) return; + const context = { user: profile.user, profile }; + + setDraft((prev) => { + let changed = false; + const next = { ...prev }; + for (const section of config.licenseType.formSchema.sections) { + for (const field of section.fields) { + if (!field.readOnly || !field.source) continue; + const value = readSourcePath(context, field.source); + if (value === undefined || value === null || value === "") continue; + if (next[section.key]?.[field.key] === value) continue; + next[section.key] = { ...next[section.key], [field.key]: value }; + changed = true; + } + } + return changed ? next : prev; + }); + }, [profile, config, detail?.application?.id, detail?.application?.formData]); const application = detail?.application; - const isAdjusting = application?.status === 'RESUBMIT_REQUIRED'; + 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 + .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 + .filter((r) => r.targetType === "DOCUMENT") + .map((r) => [r.targetKey, r.remark]), ), [openRemarks], ); @@ -219,7 +300,11 @@ export function LicenseApplicationPage() { ); } - const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status); + const readOnly = !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status); + // A DRAFT has nothing worth summarising yet, so it always opens straight + // into the wizard; every later status (including RESUBMIT_REQUIRED) opens + // to the summary first. + const showSummary = application.status !== "DRAFT" && viewingSummary; // Vessel Information and Current Ownership are separate form sections, so // ConfigDrivenSection (one instance per section) can't fill both itself — @@ -245,9 +330,15 @@ export function LicenseApplicationPage() { const nationalityField = config?.licenseType.formSchema.sections .find((s) => s.key === sectionKey) // English-pinned: same reasoning as the fill() matcher above. - ?.fields.find((f) => f.key === 'nationality' || (f.label.en ?? '').toLowerCase().includes('nationality')); + ?.fields.find( + (f) => + f.key === "nationality" || + (f.label.en ?? "").toLowerCase().includes("nationality"), + ); if (nationalityField && values[nationalityField.key]) { - values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key]; + values[nationalityField.key] = + getCountryName(values[nationalityField.key] as string) || + values[nationalityField.key]; } try { await patchSection({ @@ -257,8 +348,8 @@ export function LicenseApplicationPage() { }).unwrap(); } catch (err) { notifications.show({ - color: 'red', - title: 'Could not save', + color: "red", + title: "Could not save", message: extractErrorMessage(err), }); } @@ -267,13 +358,17 @@ export function LicenseApplicationPage() { async function handleSubmit() { setIssues([]); if (!readOnly && currentStep?.sections?.length) { - const errors = validateSections(currentStep.sections, draft, i18n.language); + const errors = validateSections( + currentStep.sections, + draft, + i18n.language, + ); setFieldErrors(errors); if (Object.keys(errors).length) { notifications.show({ - color: 'red', - title: 'Incomplete', - message: 'Complete the highlighted fields before submitting.', + color: "red", + title: "Incomplete", + message: "Complete the highlighted fields before submitting.", }); return; } @@ -282,29 +377,32 @@ export function LicenseApplicationPage() { try { if (isAdjusting) { for (const remark of openRemarks) { - await resolveRemark({ id: appId as string, remarkId: remark.id }).unwrap(); + 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.', + 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.', + color: "teal", + title: "Application submitted", + message: "You will be notified as it progresses.", }); } - navigate('/licensing/applications'); + navigate("/licensing/applications"); } catch (err) { const found = extractValidationIssues(err); setIssues(found); notifications.show({ - color: 'red', - title: 'Application incomplete', + color: "red", + title: "Application incomplete", message: found.length ? `${found.length} item(s) still need attention.` : extractErrorMessage(err), @@ -326,57 +424,62 @@ export function LicenseApplicationPage() { // 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, i18n.language); + if (currentStep.kind === "sections") { + const errors = validateSections( + currentStep.sections, + draft, + i18n.language, + ); 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.`, + color: "red", + title: "Incomplete", + message: `Complete ${count} required field${count > 1 ? "s" : ""} to continue.`, }); return false; } return true; } - if (currentStep.kind === 'staff') { + if (currentStep.kind === "staff") { const missing = config.staffRoleRequirements .filter( (role) => - (detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey).length < - role.minCount, + (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(', ')}.`, + color: "red", + title: "Staff incomplete", + message: `Still needed: ${missing.join(", ")}.`, }); return false; } return true; } - if (currentStep.kind === 'documents') { + 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)), + 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` : ''}.`, + color: "red", + title: "Documents missing", + message: `Upload: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ` and ${missing.length - 3} more` : ""}.`, }); return false; } @@ -389,8 +492,9 @@ export function LicenseApplicationPage() { 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); + if (currentStep?.kind === "sections") { + for (const section of currentStep.sections) + await saveSection(section.key); } setFieldErrors({}); setActive((s) => Math.min(steps.length - 1, s + 1)); @@ -403,8 +507,9 @@ export function LicenseApplicationPage() { return; } if (!readOnly && !(await validateCurrentStep())) return; - if (currentStep?.kind === 'sections') { - for (const section of currentStep.sections) await saveSection(section.key); + if (currentStep?.kind === "sections") { + for (const section of currentStep.sections) + await saveSection(section.key); } setFieldErrors({}); setActive(active + 1); @@ -412,19 +517,36 @@ export function LicenseApplicationPage() { return ( - +
{localized(config.licenseType.name)} - - {application.applicationNumber} ·{' '} - - {application.status.replace(/_/g, ' ')} + + + {application.applicationNumber} + + + {STATUS_LABELS[application.status]} - +
- - Fee: {config.fee ?? '—'} {config.feeCurrency} - + + + Fee: {config.fee ?? "—"} {config.feeCurrency} + + {showSummary && isAdjusting && ( + + )} +
{isAdjusting && ( @@ -448,7 +570,12 @@ export function LicenseApplicationPage() { )} {issues.length > 0 && ( - } title="Still missing" mb="md"> + } + title="Still missing" + mb="md" + > {issues.map((issue, i) => ( @@ -459,42 +586,213 @@ export function LicenseApplicationPage() { )} - - - {steps.map((step) => ( - - ))} - + {showSummary && ( + + )} - {currentStep?.kind === 'sections' && ( - - {currentStep.sections.map((section, index) => { - const locked = isAdjusting && !flaggedSections[section.key]; - return ( + {!showSummary && ( + + + {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) => (
- {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 }, + [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}`]; @@ -502,204 +800,73 @@ export function LicenseApplicationPage() { }); }} /> +
- ); - })} -
- )} - - {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 ? ( - - ) : ( - - - + ))} + 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 ? ( + + ) : ( + + + + )} + + + )} setNewStaff({ ...newStaff, fullName: e.currentTarget.value })} + onChange={(e) => + setNewStaff({ ...newStaff, fullName: e.currentTarget.value }) + } /> setNewStaff({ ...newStaff, position: e.currentTarget.value })} + onChange={(e) => + setNewStaff({ ...newStaff, position: e.currentTarget.value }) + } /> setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })} + onChange={(v) => + setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 }) + } min={0} /> @@ -729,7 +902,11 @@ export function LicenseApplicationPage() { onClick={async () => { if (!newStaff.fullName.trim() || !staffModal) return; await addStaff({ id: appId, roleKey: staffModal, ...newStaff }); - setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 }); + setNewStaff({ + fullName: "", + position: "", + yearsOfExperience: 0, + }); setStaffModal(null); refetch(); }}