From 50679614fbd54964099ac8d8044d4d975ba3a27f Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 17 Aug 2026 11:33:14 +0000 Subject: [PATCH 1/3] Application Commit --- .../components/ApplicationSummary.tsx | 88 ++ .../pages/LicenseApplicationPage.tsx | 779 +++++++++++------- 2 files changed, 566 insertions(+), 301 deletions(-) create mode 100644 apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx 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(); }} From 13c4e09f880cb90e084e4e0870ccad56684f86a6 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 17 Aug 2026 12:03:19 +0000 Subject: [PATCH 2/3] Fix --- .../pages/SeafarerRegistrationPage.tsx | 642 ++++++++++++++++++ apps/portal/src/app/router.tsx | 15 +- 2 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx new file mode 100644 index 000000000..cd0e7bee4 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx @@ -0,0 +1,642 @@ +import { useEffect, useRef, useState } from 'react'; +import { useCurrentProfile, useUpdateMyProfileMutation } from '@ema-platform/auth'; +import { useAppSelector } from '../../../store/hooks'; +import { useSaveMyAddressMutation } from '../../profile/api/address-api'; +import { + Alert, + Badge, + Box, + Button, + Card, + Divider, + FileButton, + Group, + Paper, + Select, + SimpleGrid, + Stack, + Text, + Textarea, + TextInput, + Title, + rem, +} from '@mantine/core'; +import { + IconAddressBook, + IconAlertTriangle, + IconArrowLeft, + IconArrowRight, + IconCamera, + IconCheck, + IconCircleCheck, + IconFileDescription, + IconId, + IconInfoCircle, + IconSchool, + IconUser, +} from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import { notify } from '@ema-platform/ui'; +import { BilingualInput } from '../../../components/BilingualInput'; +import type { BilingualValue } from '../../../components/BilingualInput'; +import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker'; +import { LocationPicker } from '../../location/components/LocationPicker'; + + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const NATIONALITIES = [ + 'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other', +]; +const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed']; +const GENDERS = ['Male', 'Female']; +const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other']; + +const STEPS = [ + { label: 'Personal Information' }, + { label: 'Contact Details' }, + { label: 'Documents Upload' }, + { label: 'Review & Submit' }, +]; + +interface DocSlot { + key: string; + label: string; + description: string; + required: boolean; + icon: typeof IconId; +} + +const DOC_SLOTS: DocSlot[] = [ + { key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId }, + { key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription }, + { key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool }, + { key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera }, +]; + +// --------------------------------------------------------------------------- +// Step indicator +// --------------------------------------------------------------------------- +function StepIndicator({ active, completed }: { active: number; completed: number[] }) { + return ( + + + {STEPS.map((step, i) => { + const isDone = completed.includes(i); + const isCurrent = active === i; + return ( + + + + {isDone ? ( + + ) : ( + + {i + 1} + + )} + + + {isDone ? `${step.label} ✓` : step.label} + + + + {i < STEPS.length - 1 && ( + + )} + + ); + })} + + + ); +} + +// --------------------------------------------------------------------------- +// Section heading +// --------------------------------------------------------------------------- +function SectionHead({ title }: { title: string }) { + return ( + <> + + {title} + + ); +} + +// --------------------------------------------------------------------------- +// Review row +// --------------------------------------------------------------------------- +function ReviewRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || '—'} +
+ ); +} + +// --------------------------------------------------------------------------- +// Document upload card +// --------------------------------------------------------------------------- +function DocCard({ + slot, + file, + onFile, +}: { + slot: DocSlot; + file: File | null; + onFile: (f: File | null) => void; +}) { + const resetRef = useRef<() => void>(null); + const SlotIcon = slot.icon; + return ( + + + + + +
+ + {slot.label} + {slot.required && *} + + {slot.description} +
+
+ + {file ? ( + + + {file.name} + + + ) : ( + + {(props) => ( + + )} + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export function SeafarerRegistrationPage() { + const navigate = useNavigate(); + // Every signed-in user already has a profile (`/profiles/me` provisions + // one), so registering fills that row in rather than creating a second — + // POST /profiles trips the unique user_id constraint. + const { profileId, profile } = useCurrentProfile(); + const user = useAppSelector((state) => state.auth.user); + const [active, setActive] = useState(0); + const [completed, setCompleted] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [updateProfile] = useUpdateMyProfileMutation(); + const [saveAddress] = useSaveMyAddressMutation(); + + // Step 1 — Personal Information + const [firstName, setFirstName] = useState({ en: '', am: '' }); + const [middleName, setMiddleName] = useState({ en: '', am: '' }); + const [lastName, setLastName] = useState({ en: '', am: '' }); + const [gender, setGender] = useState(null); + const [dob, setDob] = useState(null); + const [placeOfBirth, setPlaceOfBirth] = useState(''); + const [nationality, setNationality] = useState('Ethiopian'); + const [maritalStatus, setMaritalStatus] = useState(null); + const [nationalIdNumber, setNationalIdNumber] = useState(''); + const [passportNumber, setPassportNumber] = useState(''); + const [passportExpiry, setPassportExpiry] = useState(''); + + // Step 2 — Contact Details + const [mobile, setMobile] = useState(''); + const [email, setEmail] = useState(''); + const [locationId, setLocationId] = useState(null); + const [permanentAddress, setPermanentAddress] = useState(''); + const [currentAddress, setCurrentAddress] = useState(''); + const [emergencyName, setEmergencyName] = useState(''); + const [emergencyRel, setEmergencyRel] = useState(null); + const [emergencyPhone, setEmergencyPhone] = useState(''); + + // Step 3 — Documents + const [files, setFiles] = useState>({ + nationalId: null, passport: null, graduation: null, photo: null, + }); + + const setFile = (key: string) => (f: File | null) => + setFiles((prev) => ({ ...prev, [key]: f })); + + // Signup already asked for name, email and phone — start from those (and + // whatever is on the profile) instead of making the applicant retype them. + // Only blank fields are filled, so nothing typed here is overwritten. + useEffect(() => { + const [enFirst = '', ...enRest] = (user?.name.en ?? '').trim().split(/\s+/); + const [amFirst = '', ...amRest] = (user?.name.am ?? '').trim().split(/\s+/); + const orEmpty = (v?: string | null) => v ?? ''; + // Profile stores MALE / SINGLE; the selects here list Male / Single. + const title = (v: string) => v.charAt(0) + v.slice(1).toLowerCase(); + setFirstName((c) => c.en ? c : { en: profile?.firstName || enFirst, am: amFirst }); + setMiddleName((c) => c.en ? c : { en: profile?.middleName || enRest.slice(0, -1).join(' '), am: amRest.slice(0, -1).join(' ') }); + setLastName((c) => c.en ? c : { en: profile?.lastName || orEmpty(enRest.at(-1)), am: orEmpty(amRest.at(-1)) }); + if (profile?.gender) setGender((c) => c ?? title(profile.gender)); + if (profile?.dob) setDob((c) => c ?? new Date(profile.dob)); + if (profile?.pob) setPlaceOfBirth((c) => c || profile.pob); + if (profile?.maritalStatus) setMaritalStatus((c) => c ?? title(profile.maritalStatus)); + setEmail((c) => c || profile?.address?.email || user?.email || ''); + setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || ''); + if (profile?.address?.idNumber) setNationalIdNumber((c) => c || profile.address.idNumber); + if (profile?.address?.nationality) setNationality((c) => c ?? profile.address.nationality); + if (profile?.address?.streetAddress) setPermanentAddress((c) => c || orEmpty(profile.address.streetAddress)); + if (profile?.address?.emergencyContactName) setEmergencyName((c) => c || orEmpty(profile.address.emergencyContactName)); + if (profile?.address?.emergencyContactPhone) setEmergencyPhone((c) => c || orEmpty(profile.address.emergencyContactPhone)); + if (profile?.address?.emergencyContactRelation) setEmergencyRel((c) => c ?? profile.address.emergencyContactRelation); + }, [user, profile]); + + // Registration writes the profile as SEAFARER with personal details, so a + // profile in that state is a submitted registration: show it read-only + // instead of an empty wizard. "Edit" reopens the wizard on the same data. + const registered = profile?.type === 'SEAFARER' && !!profile.firstName && !!profile.dob; + const [editing, setEditing] = useState(false); + + const canNext = () => { + if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim(); + if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId; + if (active === 2) return !!files.nationalId && !!files.photo; + return true; + }; + + const next = () => { + setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]); + setActive((c) => c + 1); + }; + const prev = () => setActive((c) => c - 1); + + const handleSubmit = async () => { + if (!profileId) return; + setSubmitting(true); + try { + await updateProfile({ + id: profileId, + body: { + type: 'SEAFARER', + firstName: firstName.en, + middleName: middleName.en || undefined, + lastName: lastName.en, + gender: gender?.toUpperCase() ?? 'MALE', + dob: dob?.toISOString().split('T')[0] ?? '', + pob: placeOfBirth || undefined, + maritalStatus: maritalStatus?.toUpperCase() ?? 'SINGLE', + }, + }).unwrap(); + + await saveAddress({ + profileId, + body: { + idType: 'NID', + idNumber: nationalIdNumber, + nationality: nationality ?? 'Ethiopian', + primaryPhoneNumber: mobile, + email: email || undefined, + website: null, + streetAddress: permanentAddress || undefined, + emergencyContactName: emergencyName || undefined, + emergencyContactPhone: emergencyPhone || undefined, + emergencyContactRelation: emergencyRel || undefined, + }, + }).unwrap(); + + notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`); + setEditing(false); + setActive(0); + setCompleted([]); + } catch { + notify.error('Submission failed. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const review = ( + + + Personal Information + + + + + + + + + + + + + + + + + Contact Details + + + + + + + + {emergencyName && ( + <> + + Emergency Contact + + + + + + + )} + + + + Documents + + {DOC_SLOTS.map((slot) => ( + + {files[slot.key] ? ( + + ) : ( + + )} +
+ + {slot.label} + {slot.required && !files[slot.key] && *} + + {files[slot.key] && ( + {files[slot.key]!.name} + )} +
+
+ ))} +
+
+
+ ); + + const stepLabel = STEPS[active]?.label ?? ''; + const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck]; + const StepIcon = stepIcons[active]; + + if (registered && !editing) { + const status = profile.seafarerStatus ?? 'PENDING'; + return ( + + +
+ Seafarer Registration + + {profile.seafarerNumber + ? `Seafarer ID ${profile.seafarerNumber}` + : 'Submitted — a Seafarer ID is issued once EMA approves your registration.'} + +
+ + + {status} + + + +
+ {review} +
+ ); + } + + return ( + + {/* Page header */} +
+ New Seafarer Registration + Register a new seafarer profile — Step {active + 1} of {STEPS.length} +
+ + {/* Step indicator */} + + + {/* Card */} + + {/* Card header */} + + + + {stepLabel} + + Step {active + 1} of {STEPS.length} + + + {/* ── Step 1: Personal Information ───────────────────────────── */} + {active === 0 && ( + + + + + + + +