mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
664 lines
22 KiB
TypeScript
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;
|