Files
emaui/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx

1039 lines
36 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Container,
Divider,
Group,
Modal,
NumberInput,
Paper,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
IconAlertTriangle,
IconPencil,
IconCheck,
IconInfoCircle,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
buildWizardSteps,
conditionHolds,
extractErrorMessage,
extractValidationIssues,
useLocalized,
validateSections,
STATUS_COLORS,
STATUS_LABELS,
useAddStaffMutation,
useCreateApplicationMutation,
useGetApplicationQuery,
useGetAttachmentsQuery,
useGetLicenseTypeRequirementsQuery,
useGetMyVesselsQuery,
usePatchSectionMutation,
useRemoveStaffMutation,
useResolveRemarkMutation,
useResubmitApplicationMutation,
useSubmitApplicationMutation,
type FieldErrors,
type FormFieldConfig,
type ValidationIssue,
type Vessel,
} from "@ema-platform/api";
import {
getCountryCode,
getCountryName,
ModalFooter,
splitPersonName,
} from "@ema-platform/ui";
import {
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
RequirePermission,
useCurrentProfile,
} 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";
import { useAppSelector } from "../../../store/hooks";
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
function readSourcePath(
context: Record<string, unknown>,
path: string,
): unknown {
return path
.split(".")
.reduce<unknown>(
(acc, key) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[key]
: undefined,
context,
);
}
// Name fields predate the generic `source` metadata in some persisted form
// schemas. Keep their profile mapping here so existing applications/configs
// receive the same prefill as newly seeded schemas.
const LEGACY_PROFILE_SOURCES: Record<string, string> = {
firstName: "profile.firstName",
middleName: "profile.middleName",
lastName: "profile.lastName",
};
/**
* 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 { t, i18n } = useTranslation();
const localized = useLocalized();
const accountUser = useAppSelector((state) => state.auth.user);
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
const { profile } = useCurrentProfile();
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
// here rather than deeper down since it's the shared source of draft state.
const { data: vessels } = useGetMyVesselsQuery();
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(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.
// For a one-shot registration (e.g. seafarer) already submitted or further
// along, the API returns that existing application instead of a new draft —
// "Apply" reopens it rather than erroring, the same way it reopens a DRAFT.
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);
// 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<Record<string, Record<string, unknown>>>(
{},
);
const [issues, setIssues] = useState<ValidationIssue[]>([]);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [staffModal, setStaffModal] = useState<string | null>(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 and National ID (Fayda) number are already on file from the
// profile's Address tab — carry them into whichever section the form
// config puts those fields 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 address = profile?.address;
if (!address || !config) return;
setDraft((prev) => {
let next = prev;
const fill = (
matchField: (label: string, key: string) => boolean,
toFieldValue: (field: FormFieldConfig) => unknown,
) => {
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),
);
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),
},
};
return;
}
};
if (address.nationality) {
// Profile stores the full country name; the field always renders as
// 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"),
() => getCountryCode(address.nationality) ?? address.nationality,
);
}
if (address.idType === "NID" && address.idNumber) {
fill(
(label, key) =>
key === "idNumber" ||
key === "nationalId" ||
key === "faydaNumber" ||
label.includes("fayda") ||
label.includes("national id"),
() => address.idNumber,
);
}
return next;
});
// 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,
]);
// Generic fill for every field the config gives a `source` — the profile
// value the applicant would otherwise retype. Seafarer registration's
// Identity Details step is the case that drives this: it collects name,
// gender, DOB and national ID *in the wizard* rather than sending the
// applicant to `/profile` first, so those fields are editable and this is a
// prefill, not a display.
//
// Editable sourced fields are filled only while still blank. Re-running
// this effect (a refetched profile, a saved draft) must not overwrite what
// the applicant has since typed — for a `readOnly` field the profile stays
// authoritative, so those keep tracking it.
useEffect(() => {
if (!profile || !config) return;
// `profile.firstName/middleName/lastName` stay blank until the applicant
// saves the Maritime Profile tab once — a fresh signup arrives here
// without ever having done that. Fall back to splitting the account's
// `name.en` (the same name signup collected) so this step still
// prefills instead of opening blank.
const accountName = accountUser?.name ?? profile.user?.name;
const nameFallback = accountName?.en
? splitPersonName(accountName.en)
: null;
const context = {
user: accountUser ?? profile.user,
profile: {
...profile,
firstName: profile.firstName || nameFallback?.firstName || "",
middleName: profile.middleName || nameFallback?.middleName || "",
lastName: profile.lastName || nameFallback?.lastName || "",
},
};
setDraft((prev) => {
let changed = false;
const next = { ...prev };
for (const section of config.licenseType.formSchema.sections) {
for (const field of section.fields) {
const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
if (!source) continue;
const current = next[section.key]?.[field.key];
const untouched =
current === undefined || current === null || current === "";
if (!field.readOnly && !untouched) continue;
const value = readSourcePath(context, source);
if (value === undefined || value === null || value === "") continue;
if (current === value) continue;
next[section.key] = { ...next[section.key], [field.key]: value };
changed = true;
}
}
return changed ? next : prev;
});
}, [
profile,
accountUser,
config,
detail?.application?.id,
detail?.application?.formData,
]);
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,
language: i18n.language,
}),
[config, draft, i18n.language],
);
const sections = useMemo(
() => steps.flatMap((step) => step.sections),
[steps],
);
if (loadingConfig || !config || !appId || !application) {
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
}
// A submitted application stays editable until an officer takes it, which
// mirrors the server's own rule (`assertEditable`): an applicant who spots
// their own mistake can fix it instead of waiting to be sent back for it.
// Once claimed it locks — the officer reading it must not have the form move
// underneath them.
const editableWhileSubmitted =
application.status === "SUBMITTED" && !application.assignedOfficerId;
const readOnly =
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
!editableWhileSubmitted;
// 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 —
// it reports the pick up here and this fans it out across every section.
function handleVesselSelected(vessel: Vessel) {
for (const section of config?.licenseType.formSchema.sections ?? []) {
fillFromVessel(vessel, section.fields, (key, value) => {
setDraft((prev) => ({
...prev,
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
}));
});
}
}
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;
const values = { ...(draft[sectionKey] ?? {}) };
// The picker works in alpha-2 codes (CountrySelect); the backend, like
// the profile Address endpoint, stores the full country name.
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"),
);
if (nationalityField && values[nationalityField.key]) {
values[nationalityField.key] =
getCountryName(values[nationalityField.key] as string) ||
values[nationalityField.key];
}
try {
await patchSection({
id: appId as string,
sectionKey,
values,
}).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,
i18n.language,
);
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.",
});
navigate("/licensing/applications");
} else {
await submitApplication(appId as string).unwrap();
notifications.show({
color: "teal",
title: "Application submitted",
message: "You will be notified as it progresses.",
});
// Stays on the application rather than dropping the applicant into a
// list: they have just filled a long form and the useful next screen is
// what they submitted, with its status and — while it is still
// unclaimed — the means to correct it.
setViewingSummary(true);
}
} catch (err) {
const found = extractValidationIssues(err);
setIssues(found);
notifications.show({
color: "red",
title: "Application incomplete",
message: found.length
? t('licenseApplication.notifications.applicationIncomplete.itemsNeedAttention', { count: found.length })
: extractErrorMessage(err),
});
}
}
const currentStep = steps[active];
/**
* Checks one step before moving past it.
*
* 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.
*
* Takes the step rather than reading `currentStep`, so a jump ahead can
* check each step it passes over instead of only the one being left.
*/
async function validateStep(index: number): Promise<boolean> {
const step = steps[index];
// The wizard does not render until the configuration has loaded, but this
// is declared above that guard, so narrow it here too.
if (!step || !config) return true;
if (step.kind === "sections") {
const errors = validateSections(step.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.`,
});
return false;
}
return true;
}
if (step.kind === "staff") {
const missing = config.staffRoleRequirements
.filter(
(role) =>
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey)
.length < role.minCount,
)
.map((role) =>
t('licenseApplication.notifications.staffIncomplete.roleRequired', {
name: localized(role.name),
count: role.minCount,
}),
);
if (missing.length) {
notifications.show({
color: "red",
title: "Staff incomplete",
message: `Still needed: ${missing.join(", ")}.`,
});
return false;
}
return true;
}
if (step.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) {
const shown = missing.slice(0, 3).join(', ');
const extra =
missing.length > 3
? ` ${t('licenseApplication.notifications.documentsMissing.andMore', {
count: missing.length - 3,
})}`
: '';
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;
}
/** The step the applicant is on — what `Continue` validates. */
async function validateCurrentStep(): Promise<boolean> {
return validateStep(active);
}
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 every step passed.
*
* `target` used to be discarded on the forward path — the handler validated
* the current step and then advanced by exactly one, so clicking "4" from
* step 1 landed on step 2. Two steps then showed the same content one click
* apart, which reads as a broken wizard rather than a refused jump, and made
* the later sections look absent entirely.
*
* Each step between here and `target` is validated and saved in order, so a
* jump ahead cannot skip a required field the way a plain `setActive` would.
* The walk stops at the first step that fails, leaving the applicant on it
* with its errors showing.
*/
async function goToStep(target: number) {
if (target <= active) {
setActive(target);
return;
}
for (let step = active; step < target; step++) {
if (!readOnly && !(await validateStep(step))) {
setActive(step);
return;
}
const passed = steps[step];
if (passed?.kind === "sections") {
for (const section of passed.sections) await saveSection(section.key);
}
}
setFieldErrors({});
setActive(target);
}
return (
<Container size="lg" py="md">
<Group justify="space-between" mb="xs" align="flex-start">
<div>
<Title order={3}>{localized(config.licenseType.name)}</Title>
<Group gap="xs" mt={4}>
<Text size="sm" c="dimmed">
{application.applicationNumber}
</Text>
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[application.status]}
>
{STATUS_LABELS[application.status]}
</Badge>
</Group>
</div>
<Group gap="md" align="center">
<Text size="sm" c="dimmed">
Fee: {config.fee ?? "—"} {config.feeCurrency}
</Text>
{showSummary && !readOnly && (
<Button
size="xs"
variant="default"
leftSection={<IconPencil size={14} />}
onClick={() => setViewingSummary(false)}
>
Edit details
</Button>
)}
</Group>
</Group>
{isAdjusting && (
<Alert
color="orange"
icon={<IconAlertTriangle size={16} />}
title={t('licenseApplication.correctionsRequested.title')}
mb="md"
>
<Stack gap={4}>
{openRemarks.map((remark) => (
<Text size="sm" key={remark.id}>
<b>{remark.targetKey}</b>: {remark.remark}
</Text>
))}
<Text size="xs" c="dimmed" mt={4}>
{t('licenseApplication.correctionsRequested.onlyListed')}
</Text>
</Stack>
</Alert>
)}
{showSummary && editableWhileSubmitted && (
<Alert
color="blue"
icon={<IconInfoCircle size={16} />}
title="Submitted — still correctable"
mb="md"
>
Your application is in the queue. You can still change any detail
until a reviewing officer picks it up; after that, corrections happen
only if they ask for them.
</Alert>
)}
{issues.length > 0 && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title="Still missing"
mb="md"
>
<Stack gap={2}>
{issues.map((issue, i) => (
<Text size="sm" key={i}>
{issue.message}
</Text>
))}
</Stack>
</Alert>
)}
{showSummary && (
<ApplicationSummary
sections={sections}
formData={application.formData}
localized={localized}
config={config}
attachments={attachments}
applicationId={appId as string}
/>
)}
{!showSummary && (
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{steps.map((step) => (
<Stepper.Step key={step.key} label={step.label} />
))}
</Stepper>
{currentStep?.kind === "sections" && (
<Stack gap="lg">
{currentStep.sections.map((section, index) => {
const locked = isAdjusting && !flaggedSections[section.key];
return (
<div key={section.key}>
{index > 0 && <Divider mb="lg" />}
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
{localized(section.title)}
</Text>
{locked && (
<Alert
color="gray"
icon={<IconInfoCircle size={16} />}
mb="md"
>
This section was accepted and is locked for this round.
</Alert>
)}
<ConfigDrivenSection
section={section}
values={draft[section.key] ?? {}}
formData={draft}
onVesselSelected={handleVesselSelected}
errors={fieldErrors}
vessels={vessels}
disabled={readOnly || locked}
onChange={(key, value) => {
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;
});
}}
/>
</div>
);
})}
</Stack>
)}
{currentStep?.kind === "staff" && (
<Stack>
{config.staffRoleRequirements.map((role) => {
const members = (detail?.staff ?? []).filter(
(s) => s.roleKey === role.roleKey,
);
return (
<Card withBorder key={role.roleKey} padding="md">
<Group justify="space-between" mb="xs">
<div>
<Text fw={600} size="sm">
{localized(role.name)}
</Text>
<Text size="xs" c="dimmed">
{members.length} of {role.minCount} required
{role.requiredEvidence.length > 0 &&
` · each needs ${role.requiredEvidence
.filter((e) => e.mandatory)
.map((e) => localized(e.label))
.join(", ")}`}
</Text>
</div>
<Group gap="xs">
{members.length >= role.minCount && (
<Badge
color="teal"
size="sm"
leftSection={<IconCheck size={10} />}
>
complete
</Badge>
)}
{!readOnly && (
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={14} />}
onClick={() => setStaffModal(role.roleKey)}
>
Add
</Button>
)}
</Group>
</Group>
<Stack gap="xs">
{members.map((member) => (
<Card
withBorder
key={member.id}
padding="sm"
radius="sm"
>
<Group
justify="space-between"
mb={member.id ? "xs" : 0}
>
<div>
<Text size="sm" fw={500}>
{member.fullName}
</Text>
<Text size="xs" c="dimmed">
{member.position ?? "—"}
{member.yearsOfExperience
? ` · ${member.yearsOfExperience} yrs`
: ""}
</Text>
</div>
{!readOnly && (
<ActionIcon
variant="subtle"
color="red"
onClick={async () => {
await removeStaff({
id: appId,
staffId: member.id,
});
refetch();
}}
>
<IconTrash size={16} />
</ActionIcon>
)}
</Group>
<StaffEvidence
staffId={member.id}
evidence={role.requiredEvidence}
readOnly={readOnly}
onUploaded={refetch}
/>
</Card>
))}
</Stack>
</Card>
);
})}
</Stack>
)}
{currentStep?.kind === "documents" && (
<DocumentSlots
requirements={config.documentRequirements}
attachments={attachments}
formData={draft}
ownerType="APPLICATION"
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={isAdjusting}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();
refetch();
}}
/>
)}
{currentStep?.kind === "review" && (
<Stack>
{currentStep.sections.map((section) => (
<div key={section.key}>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
{localized(section.title)}
</Text>
<ConfigDrivenSection
section={section}
onVesselSelected={handleVesselSelected}
values={draft[section.key] ?? {}}
formData={draft}
errors={fieldErrors}
vessels={vessels}
disabled={readOnly}
onChange={(key, value) => {
setDraft((prev) => ({
...prev,
[section.key]: {
...(prev[section.key] ?? {}),
[key]: value,
},
}));
setFieldErrors((prev) => {
const next = { ...prev };
delete next[`${section.key}.${key}`];
return next;
});
}}
/>
<Divider my="lg" />
</div>
))}
<Title order={5}>Review</Title>
{sections.map((section) => (
<div key={section.key}>
<Text fw={600} size="sm" mb={4}>
{localized(section.title)}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{(section.fields ?? [])
.filter((f) => conditionHolds(f.showWhen, draft))
.map((field) => (
<Table.Tr key={field.key}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{localized(field.label)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{String(draft[section.key]?.[field.key] ?? "—")}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Divider my="sm" />
</div>
))}
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
onClick={() => setActive((s) => Math.max(0, s - 1))}
disabled={active === 0}
>
Back
</Button>
{active < steps.length - 1 ? (
<Button onClick={handleContinue}>Continue</Button>
) : (
<RequirePermission
anyOf={
isAdjusting
? [PORTAL_PERMISSIONS.RESUBMIT_APPLICATION]
: [LICENSE_PERMISSIONS.SUBMIT_APPLICATION]
}
hideOnly
>
<Button
color="teal"
loading={submitting || resubmitting}
disabled={readOnly}
onClick={handleSubmit}
>
{isAdjusting ? "Resubmit corrections" : "Submit application"}
</Button>
</RequirePermission>
)}
</Group>
</Paper>
)}
<Modal
opened={Boolean(staffModal)}
onClose={() => setStaffModal(null)}
title={t('licenseApplication.staff.addStaffMember')}
>
<Stack>
<TextInput
label={t('licenseApplication.staff.fullName')}
withAsterisk
value={newStaff.fullName}
onChange={(e) =>
setNewStaff({ ...newStaff, fullName: e.currentTarget.value })
}
/>
<TextInput
label={t('licenseApplication.staff.position')}
value={newStaff.position}
onChange={(e) =>
setNewStaff({ ...newStaff, position: e.currentTarget.value })
}
/>
<NumberInput
label={t('licenseApplication.staff.yearsOfExperience')}
value={newStaff.yearsOfExperience}
onChange={(v) =>
setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })
}
min={0}
/>
<ModalFooter>
<Button
onClick={async () => {
if (!newStaff.fullName.trim() || !staffModal) return;
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
setNewStaff({
fullName: "",
position: "",
yearsOfExperience: 0,
});
setStaffModal(null);
refetch();
}}
>
{t('licenseApplication.staff.add')}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Container>
);
}
export default LicenseApplicationPage;