mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 08:48:12 +00:00
1039 lines
36 KiB
TypeScript
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;
|