Remove obsolete test artifacts and error context files for seafarer registration tests

This commit is contained in:
Nati
2026-08-19 05:14:43 +00:00
parent 99eb55c366
commit d44977cbf3
47 changed files with 1115 additions and 3026 deletions

View File

@@ -1,10 +1,23 @@
import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core";
import {
Badge,
Divider,
Group,
Grid,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import {
conditionHolds,
displayFieldValue,
type Attachment,
type FormFieldConfig,
type FormSectionConfig,
type LicenseTypeRequirements,
} from "@ema-platform/api";
import { useDateDisplayer } from "@ema-platform/shared";
import { useTranslation } from "react-i18next";
import { DocumentSlots } from "./DocumentSlots";
interface Props {
@@ -33,42 +46,83 @@ export function ApplicationSummary({
attachments,
applicationId,
}: Props) {
return (
<Paper withBorder p="lg" radius="md">
<Stack gap="lg">
{sections.map((section) => (
<div key={section.key}>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
{localized(section.title)}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{(section.fields ?? [])
.filter((f) => conditionHolds(f.showWhen, formData))
.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(formData[section.key]?.[field.key] ?? "—")}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
))}
const showDate = useDateDisplayer();
const { i18n } = useTranslation();
<div>
<Divider mb="md" />
// Shared with the officer's review screen, so the applicant and the reviewer
// never read the same answer two different ways.
const display = (field: FormFieldConfig, raw: unknown) =>
displayFieldValue(field, raw, {
language: i18n.language,
showDate,
currency: config.feeCurrency,
}) || "—";
return (
<Stack gap="md">
{sections.map((section) => {
const fields = (section.fields ?? []).filter((f) =>
conditionHolds(f.showWhen, formData),
);
if (fields.length === 0) return null;
return (
<Paper withBorder p="lg" radius="md" key={section.key}>
<Group justify="space-between" align="center" mb="xs">
<Title order={5}>{localized(section.title)}</Title>
<Badge variant="light" color="gray" size="sm">
{fields.length} {fields.length === 1 ? "detail" : "details"}
</Badge>
</Group>
{localized(section.description) && (
<Text fz="xs" c="dimmed" mb="sm">
{localized(section.description)}
</Text>
)}
<Divider mb="md" />
{/* Label above value in two columns — a definition list reads far
better than a bordered grid when most answers are short. */}
<Grid gutter="md">
{fields.map((field) => {
const value = display(
field,
formData[section.key]?.[field.key],
);
const answered = value !== "—";
return (
<Grid.Col
span={{
base: 12,
sm: field.type === "TEXTAREA" ? 12 : 6,
}}
key={field.key}
>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
{localized(field.label)}
</Text>
<Text
fz="sm"
mt={2}
c={answered ? undefined : "dimmed"}
fs={answered ? undefined : "italic"}
style={{ wordBreak: "break-word" }}
>
{answered ? value : "Not provided"}
</Text>
</Grid.Col>
);
})}
</Grid>
</Paper>
);
})}
<Paper withBorder p="lg" radius="md">
<Title order={5} mb="sm">
Documents
</Title>
<Divider mb="md" />
<DocumentSlots
requirements={config.documentRequirements}
attachments={attachments}
@@ -81,8 +135,7 @@ export function ApplicationSummary({
// requires the callback.
}}
/>
</div>
</Stack>
</Paper>
</Paper>
</Stack>
);
}

View File

@@ -23,6 +23,7 @@ import {
} from "@mantine/core";
import {
IconAlertTriangle,
IconPencil,
IconCheck,
IconInfoCircle,
IconPlus,
@@ -309,7 +310,16 @@ export function LicenseApplicationPage() {
);
}
const readOnly = !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status);
// 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.
@@ -397,6 +407,7 @@ export function LicenseApplicationPage() {
title: "Resubmitted",
message: "Your corrections were sent back to the reviewing officer.",
});
navigate("/licensing/applications");
} else {
await submitApplication(appId as string).unwrap();
notifications.show({
@@ -404,8 +415,12 @@ export function LicenseApplicationPage() {
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);
}
navigate("/licensing/applications");
} catch (err) {
const found = extractValidationIssues(err);
setIssues(found);
@@ -571,10 +586,11 @@ export function LicenseApplicationPage() {
<Text size="sm" c="dimmed">
Fee: {config.fee ?? "—"} {config.feeCurrency}
</Text>
{showSummary && isAdjusting && (
{showSummary && !readOnly && (
<Button
size="xs"
variant="default"
leftSection={<IconPencil size={14} />}
onClick={() => setViewingSummary(false)}
>
Edit details
@@ -603,6 +619,19 @@ export function LicenseApplicationPage() {
</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"

View File

@@ -30,14 +30,22 @@ import {
IconX,
} from '@tabler/icons-react';
interface ApplicationSummary {
id: string;
applicationId: string;
status: string;
submittedAt: string;
}
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
interface SeamanBookOverview {
application: {
id: string;
applicationId: string;
status: string;
submittedAt: string;
} | null;
application: ApplicationSummary | null;
/**
* The Basic Training Certificate opened alongside the book by an approved
* seafarer registration — a separate application, separately numbered and
* separately billed, so it is shown as its own card rather than merged in.
*/
btcApplication: ApplicationSummary | null;
book: {
id: string;
issuedDate: string;
@@ -122,6 +130,76 @@ function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
);
}
/**
* One in-flight application: its number, where it stands, and the stages left.
*
* Shared by the Seaman Book and the BTC because an approved registration opens
* both and they move independently — the book waits on a TRB inspection while
* the BTC goes straight to payment, so a single merged card would have to lie
* about one of them.
*/
function ApplicationCard({
title,
application,
children,
}: {
title: string;
application: ApplicationSummary;
children?: React.ReactNode;
}) {
const activeStep = stageIndexFor(application.status);
return (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>
{title} {application.id}
</Text>
<Text fz="xs" c="dimmed">
{/* An approved seafarer registration opens this application as a
draft, so it can be here before anyone has filed it. Calling
that "Submitted" would misreport where it stands. */}
{application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '}
{formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge>
</Group>
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
{children}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -134,6 +212,7 @@ export function SeamanBookPage() {
});
const application = data?.application ?? null;
const btcApplication = data?.btcApplication ?? null;
const eligibility = data?.eligibility;
const bstItems = eligibility?.bstModules ?? [];
const bstDone = bstItems.filter((b) => b.done).length;
@@ -141,9 +220,10 @@ export function SeamanBookPage() {
// The server decides: the same checklist gates the submission, so a screen
// that judged eligibility for itself could offer a button the API refuses.
const isEligible = data?.eligible ?? false;
const submitted = Boolean(application);
const activeStep = stageIndexFor(application?.status);
// Either service already being in flight means there is nothing to apply for
// here — an approved registration opens both, so offering "Apply" alongside
// them would invite a duplicate the server refuses anyway.
const submitted = Boolean(application || btcApplication);
return (
<Stack gap="md">
@@ -156,59 +236,22 @@ export function SeamanBookPage() {
</Text>
</div>
{/* Active application status */}
{/* Active application status — one card per service in flight. */}
{application && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed">
{/* An approved seafarer registration opens this application
as a draft, so it can be here before anyone has filed it.
Calling that "Submitted" would misreport where it stands. */}
{application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '}
{formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge>
</Group>
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
<ApplicationCard title="Seaman Book" application={application}>
{data?.book && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
Please visit the EMA office to collect it, bringing your National ID.
</Alert>
)}
</Paper>
</ApplicationCard>
)}
{btcApplication && (
<ApplicationCard
title="Basic Training Certificate"
application={btcApplication}
/>
)}
{/* No active application — eligibility + apply */}