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

664 lines
22 KiB
TypeScript

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<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.
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<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 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 (
<Center h={400}>
<Loader />
</Center>
);
}
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<boolean> {
// 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 (
<Container size="lg" py="md">
<Group justify="space-between" mb="xs">
<div>
<Title order={3}>{localized(config.licenseType.name)}</Title>
<Text size="sm" c="dimmed">
{application.applicationNumber} ·{' '}
<Badge size="sm" variant="light">
{application.status.replace(/_/g, ' ')}
</Badge>
</Text>
</div>
<Text size="sm" c="dimmed">
Fee: {config.fee ?? '—'} {config.feeCurrency}
</Text>
</Group>
{isAdjusting && (
<Alert
color="orange"
icon={<IconAlertTriangle size={16} />}
title="Corrections requested"
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}>
Only the items listed above can be changed.
</Text>
</Stack>
</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>
)}
<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}
errors={fieldErrors}
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}
values={draft[section.key] ?? {}}
formData={draft}
errors={fieldErrors}
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>
) : (
<Button
color="teal"
loading={submitting || resubmitting}
disabled={readOnly}
onClick={handleSubmit}
>
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
</Button>
)}
</Group>
</Paper>
<Modal
opened={Boolean(staffModal)}
onClose={() => setStaffModal(null)}
title="Add staff member"
>
<Stack>
<TextInput
label="Full name"
withAsterisk
value={newStaff.fullName}
onChange={(e) => setNewStaff({ ...newStaff, fullName: e.currentTarget.value })}
/>
<TextInput
label="Position"
value={newStaff.position}
onChange={(e) => setNewStaff({ ...newStaff, position: e.currentTarget.value })}
/>
<NumberInput
label="Years of experience"
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();
}}
>
Add
</Button>
</ModalFooter>
</Stack>
</Modal>
</Container>
);
}
export default LicenseApplicationPage;