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

@@ -0,0 +1,108 @@
import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { ApplicationApplicant } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
interface ApplicantCardProps {
applicant: ApplicationApplicant;
}
/**
* Who the reviewer is deciding about.
*
* A company licence names itself in the page title (`companyName`); a seafarer
* registration has no company, so the officer's screen led with an application
* number and the human behind it was somewhere in the form answers. This puts
* the identity where it belongs on a person-centric review: name, national ID,
* contact, and — for a seafarer who already holds one — their number and
* standing, which is what says whether this is a first registration or a
* duplicate.
*
* Read-only and sourced from the profile, not the form: this is the record the
* registration will be written onto, so a reviewer comparing the two is exactly
* the intended use.
*/
export function ApplicantCard({ applicant }: ApplicantCardProps) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const fullName = [applicant.firstName, applicant.middleName, applicant.lastName]
.filter(Boolean)
.join(' ');
const initials = [applicant.firstName, applicant.lastName]
.filter(Boolean)
.map((part) => part?.[0]?.toUpperCase() ?? '')
.join('');
return (
<Paper withBorder p="md">
<Group gap="sm" wrap="nowrap" align="flex-start" mb="sm">
<Avatar radius="xl" color="blue" variant="light">
{initials || '—'}
</Avatar>
<div style={{ minWidth: 0 }}>
<Text fw={600} size="sm" style={{ wordBreak: 'break-word' }}>
{fullName || t('review.nameMissing', 'Name not on profile')}
</Text>
{applicant.seafarerNumber ? (
<Group gap={4} mt={2}>
<Text size="xs" c="dimmed">
{applicant.seafarerNumber}
</Text>
{applicant.seafarerStatus && (
<Badge
size="xs"
variant="light"
color={applicant.seafarerStatus === 'ACTIVE' ? 'teal' : 'orange'}
>
{applicant.seafarerStatus}
</Badge>
)}
</Group>
) : (
<Text size="xs" c="dimmed" mt={2}>
{t('review.notYetRegistered', 'Not yet registered')}
</Text>
)}
</div>
</Group>
<Stack gap={6}>
<Row label={t('review.applicantGender', 'Gender')} value={applicant.gender} />
<Row
label={t('review.applicantDob', 'Date of birth')}
value={applicant.dob ? showDate(applicant.dob) : null}
/>
<Row
label={t('review.applicantNationality', 'Nationality')}
value={applicant.nationality}
/>
<Row
// The id type is the label, so a Fayda number is not read as a passport.
label={applicant.idType ?? t('review.applicantId', 'National ID')}
value={applicant.idNumber}
/>
<Row
label={t('review.applicantPhone', 'Phone')}
value={applicant.primaryPhoneNumber}
/>
<Row label={t('review.applicantEmail', 'Email')} value={applicant.email} />
</Stack>
</Paper>
);
}
/** One label/value line, omitted entirely when there is nothing to show. */
function Row({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<Group justify="space-between" gap="xs" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="xs" ta="right" style={{ wordBreak: 'break-word' }}>
{value}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,265 @@
import {
Badge,
Card,
Checkbox,
Divider,
Grid,
Group,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
conditionHolds,
displayFieldValue,
useLocalized,
type FormFieldConfig,
type FormSectionConfig,
} from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
/** A section as it will be rendered: config where there is some, key otherwise. */
interface ResolvedSection {
key: string;
title: string;
description?: string;
fields: { field: FormFieldConfig; value: unknown }[];
}
interface FormDetailsTabProps {
/** The application's answers, keyed by section. */
formData: Record<string, Record<string, unknown>>;
/** The licence type's form schema — the order and labels to render by. */
configSections: FormSectionConfig[];
currency?: string;
/** sectionKey -> remark. Owned by the review page. */
flags: Record<string, { remark: string }>;
onToggleFlag: (sectionKey: string) => void;
onFlagRemark: (sectionKey: string, remark: string) => void;
/** Resolves a location id to a readable path, when the tree is loaded. */
resolveLocation?: (locationId: string) => string | undefined;
}
/**
* What the applicant actually filled in, as the reviewing officer reads it.
*
* Replaces a set of bordered key/value tables built by walking `formData`.
* Three things were wrong with that, all of them worse on a person-centric
* registration than on a company licence:
*
* - Values were printed with `String(v)`, so a reviewer deciding on a seafarer
* read `O_POSITIVE`, `DECK` and `true` — database codes, not the answers
* anybody chose. Now resolved through the same field config that rendered
* the input, shared with the applicant's own summary (`displayFieldValue`).
* - Order came from jsonb key order, which is arbitrary: the declaration could
* appear above the emergency contact. Now the schema's `sortOrder` decides,
* which is the order the applicant filled them in.
* - A location answer is a uuid. Shown raw it told the reviewer nothing;
* resolved, it reads "Addis Ababa → Bole → Woreda 03".
*/
export function FormDetailsTab({
formData,
configSections,
currency,
flags,
onToggleFlag,
onFlagRemark,
resolveLocation,
}: FormDetailsTabProps) {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const showDate = useDateDisplayer();
const sections = resolveSections();
/**
* Sections in schema order, each with its fields in schema order.
*
* Anything present in `formData` but absent from the schema is still shown,
* appended after the configured sections — a stale answer from a since-edited
* form is exactly the kind of thing a reviewer needs to see, not something to
* hide because the config moved on.
*/
function resolveSections(): ResolvedSection[] {
const configured = [...configSections]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((section) => {
const values = formData[section.key] ?? {};
const fields = [...(section.fields ?? [])]
.filter((f) => conditionHolds(f.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((field) => ({ field, value: values[field.key] }));
return {
key: section.key,
title: localized(section.title) || section.key,
description: localized(section.description) || undefined,
fields,
};
})
// A section the applicant never reached is noise on a review screen.
.filter((s) => s.fields.some((f) => hasValue(f.value)));
const configuredKeys = new Set(configSections.map((s) => s.key));
const orphans: ResolvedSection[] = Object.entries(formData)
.filter(([key, values]) => !configuredKeys.has(key) && values)
.map(([key, values]) => ({
key,
title: humanise(key),
fields: Object.entries(values).map(([fieldKey, value]) => ({
// No config to render by, so it is treated as free text under a
// humanised key rather than dropped.
field: { key: fieldKey, label: { en: humanise(fieldKey) }, type: 'TEXT' } as FormFieldConfig,
value,
})),
}));
return [...configured, ...orphans];
}
function display(field: FormFieldConfig, value: unknown): string {
// A location is stored as a tree id; the reviewer needs the place.
if (isLocationField(field) && typeof value === 'string' && value) {
return resolveLocation?.(value) ?? value;
}
return displayFieldValue(field, value, {
language: i18n.language,
showDate,
currency,
});
}
return (
<Grid>
{sections.map((section) => {
const flagged = Boolean(flags[section.key]);
const missing = section.fields.filter((f) => !hasValue(f.value)).length;
return (
<Grid.Col span={12} key={section.key}>
<Card withBorder padding="md" radius="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{section.title}
</Text>
{missing > 0 && (
<Tooltip
label={t(
'review.missingAnswers',
'Left blank by the applicant',
)}
>
<Badge
size="xs"
color="gray"
variant="light"
leftSection={<IconAlertTriangle size={10} />}
>
{missing}
</Badge>
</Tooltip>
)}
</Group>
{section.description && (
<Text size="xs" c="dimmed" mt={2}>
{section.description}
</Text>
)}
</div>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={flagged}
onChange={() => onToggleFlag(section.key)}
style={{ flexShrink: 0 }}
/>
</Group>
<Divider my="sm" />
{/* Label above value, two per row — a reviewer scans a definition
list far faster than a bordered table of the same answers. */}
<Grid gutter="sm">
{section.fields.map(({ field, value }) => {
const text = display(field, value);
const answered = hasValue(value) && text !== '';
return (
<Grid.Col
span={{ base: 12, sm: field.type === 'TEXTAREA' ? 12 : 6 }}
key={field.key}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{localized(field.label) || field.key}
</Text>
<Group gap={4} wrap="nowrap" align="center" mt={2}>
{answered && isLocationField(field) && (
<IconMapPin size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
)}
<Text
size="sm"
c={answered ? undefined : 'dimmed'}
fs={answered ? undefined : 'italic'}
style={{ wordBreak: 'break-word' }}
>
{answered
? text
: t('review.notProvided', 'Not provided')}
</Text>
</Group>
</Grid.Col>
);
})}
</Grid>
{flagged && (
<TextInput
mt="sm"
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
// Flagging without saying why is what the applicant would
// receive: "fix this section", and nothing else.
error={
flags[section.key].remark.trim()
? null
: t('review.correctionRequired', 'Say what must be corrected')
}
value={flags[section.key].remark}
onChange={(e) => {
// Read here, not inside the updater: React nulls
// `currentTarget` when the handler returns, and the updater
// runs afterwards during the re-render.
onFlagRemark(section.key, e.currentTarget.value);
}}
/>
)}
</Card>
</Grid.Col>
);
})}
</Grid>
);
}
function hasValue(value: unknown): boolean {
return value !== null && value !== undefined && value !== '';
}
/** English-pinned, like the portal's own location override. */
function isLocationField(field: Pick<FormFieldConfig, 'key' | 'label'>): boolean {
return (
field.key === 'locationId' ||
(field.label?.en ?? '').trim().toLowerCase() === 'location'
);
}
function humanise(key: string): string {
const spaced = key.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
}

View File

@@ -260,6 +260,28 @@ export interface ResolveContext {
hasPendingInspection: boolean;
}
/**
* Action ids that are workflow events, so `availableEvents` decides them.
*
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
* screens and side effects rather than transitions, and the server has no
* opinion on them — those keep using their own `from` list.
*/
const WORKFLOW_EVENT_IDS = new Set<ActionId>([
'claim',
'assign',
'escalate',
'hold',
'resume',
'complete-review',
'approve-documents',
'record-inspection',
'final-approve',
'request-adjustment',
'reject',
'confirm-payment',
]);
/**
* Which actions to render, and for each, whether it can fire and why not.
*
@@ -267,16 +289,35 @@ export interface ResolveContext {
* are merely unavailable right now are kept and disabled with a reason, so the
* officer can see what the next step would be rather than wondering whether
* the screen is broken.
*
* For anything that is a workflow event, `detail.availableEvents` is the
* authority on what fires from here — it comes from the same transition table
* the server validates against, and it is workflow-profile aware. The local
* `from` lists describe the licence course only, so a registration (which skips
* evaluation and inspection, and approves straight out of UNDER_REVIEW) was
* offered Complete Review — rejected server-side with
* `event_not_available_for_service` — while Final Approve, the one action that
* would work, was hidden.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
const serverEvents = detail.availableEvents;
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
// Status-scoped actions vanish outside their stage rather than piling up
// as a column of permanently dead buttons.
if (action.from && !action.from.includes(app.status)) return [];
if (WORKFLOW_EVENT_IDS.has(action.id)) {
// Tolerate an older server that sends no list rather than rendering an
// empty action bar.
if (serverEvents?.length && !serverEvents.includes(action.id)) return [];
if (!serverEvents?.length && action.from && !action.from.includes(app.status)) {
return [];
}
} else if (action.from && !action.from.includes(app.status)) {
return [];
}
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.

View File

@@ -87,6 +87,23 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
// Person-centric: no company entity, no capital threshold, no staff roles.
detailSections: ['overview', 'documents'],
},
// Opened automatically when a registration is approved, and reviewed like any
// other person-centric service. Listed explicitly because neither key matches
// the certificate prefixes below, so both fell through to the company-shaped
// default and offered an officer Company, Financials and Staff tabs for an
// application about one person.
SEAMAN_BOOK: {
key: 'SEAMAN_BOOK',
icon: IconId,
// Its own TRB inspection is a real stage, unlike the other personal
// services, so the inspection tab stays.
detailSections: ['overview', 'documents', 'inspection'],
},
BTC_BASIC_TRAINING: {
key: 'BTC_BASIC_TRAINING',
icon: IconShieldCheck,
detailSections: ['overview', 'documents'],
},
VESSEL_REGISTRATION: {
key: 'VESSEL_REGISTRATION',
icon: IconAnchor,

View File

@@ -4,8 +4,6 @@ import {
ActionIcon,
Alert,
Badge,
Card,
Checkbox,
Container,
Grid,
Group,
@@ -15,11 +13,9 @@ import {
SegmentedControl,
Skeleton,
Stack,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Timeline,
Title,
@@ -38,6 +34,7 @@ import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
extractErrorMessage,
useLocalized,
useApproveDocumentsMutation,
@@ -77,6 +74,9 @@ import {
} from '../../components/DecisionConfirmModal';
import { ActivityRail } from '../../components/ActivityRail';
import { DocumentsTab } from '../../components/DocumentsTab';
import { FormDetailsTab } from '../../components/FormDetailsTab';
import { ApplicantCard } from '../../components/ApplicantCard';
import { useGetLocationsQuery } from '../../../location/api/location-api';
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
import { computeSla } from '../../sla';
import { reviewStaffColumns } from './columns';
@@ -258,6 +258,45 @@ export function LicenseReviewPage() {
});
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
// Location answers are tree ids. The picker the applicant used resolves them
// client-side from the same list, so the reviewer reads the place rather than
// the uuid. Fetched only when the form actually has a location field.
//
// Above the early returns because it is a hook: React requires the same hook
// order on every render, and the loading and error branches return before the
// application is known.
const needsLocations = (
requirements?.licenseType.formSchema.sections ?? []
).some((section) =>
(section.fields ?? []).some(
(f) =>
f.key === 'locationId' ||
(f.label?.en ?? '').trim().toLowerCase() === 'location',
),
);
const { data: locationsRes } = useGetLocationsQuery(
{ take: 10000 },
{ skip: !needsLocations },
);
const resolveLocation = useMemo(() => {
const all = locationsRes?.items ?? [];
if (all.length === 0) return undefined;
const byId = new Map(all.map((loc) => [loc.id, loc]));
return (locationId: string) => {
// Walks to the root so the answer reads as a place, not a leaf name:
// "Woreda 03" alone does not say which sub-city it belongs to. Bounded by
// the map size so a cyclic tree cannot spin the render.
const path: string[] = [];
let current = byId.get(locationId);
let hops = 0;
while (current && hops++ <= byId.size) {
path.unshift(localized(current.names) || current.code);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path.length ? path.join(' → ') : undefined;
};
}, [locationsRes, localized]);
if (isLoading) {
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
return (
@@ -515,19 +554,40 @@ export function LicenseReviewPage() {
}
const sections = presentation.detailSections;
const formSections = Object.entries(app.formData ?? {});
const hasFormAnswers = Object.keys(app.formData ?? {}).length > 0;
// The bilingual section/field labels the applicant's wizard renders — this
// page already fetches them (`requirements` above) but used to fall back to
// the raw formData keys, so an officer saw `vesselId` instead of a label in
// either language.
const configSections = requirements?.licenseType.formSchema.sections ?? [];
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
// A person-centric service has no company, so the company-shaped facts are
// not merely empty — they are the wrong question. TIN is hidden rather than
// shown blank, and the applicant's own identity card takes its place.
const isPersonal = !app.companyName;
const applicant = data.applicant;
const applicantFullName = [
applicant?.firstName,
applicant?.middleName,
applicant?.lastName,
]
.filter(Boolean)
.join(' ');
// The profile is the reliable name for a personal service — `formData.account`
// holds one only for applications filed after that field was added, and the
// application number identifies the paperwork rather than the person.
const headerName =
app.companyName ||
applicantFullName ||
applicantOrCompanyName(app) ||
app.applicationNumber;
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{app.companyName ?? app.applicationNumber}</Title>
<Title order={3}>{headerName}</Title>
<Group gap="xs">
<Text size="sm" c="dimmed">
{app.applicationNumber}
@@ -565,13 +625,21 @@ export function LicenseReviewPage() {
{/* Zone 1 — sticky summary rail. */}
<Grid.Col span={{ base: 12, md: 3 }}>
<Stack style={{ position: 'sticky', top: 16 }}>
{/* Who, before what: a person-centric review is about the applicant,
and the licence facts below are the context. */}
{isPersonal && applicant && <ApplicantCard applicant={applicant} />}
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.summary', 'Summary')}
</Text>
<Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
{/* A person has no TIN; showing the row blank invited the
reviewer to wonder what was missing. */}
{!isPersonal && (
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
)}
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
<SummaryRow
label={t('review.submitted', 'Submitted')}
@@ -653,7 +721,7 @@ export function LicenseReviewPage() {
<Tabs defaultValue={sections[0]}>
<Tabs.List mb="md">
{/* Tabs with nothing behind them are not rendered at all. */}
{sections.includes('overview') && formSections.length > 0 && (
{sections.includes('overview') && hasFormAnswers && (
<Tabs.Tab value="overview">{t('review.tabs.overview', 'Overview')}</Tabs.Tab>
)}
{sections.includes('financials') && (
@@ -675,83 +743,20 @@ export function LicenseReviewPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<Stack>
{formSections.map(([sectionKey, values]) => {
const sectionConfig = sectionsByKey.get(sectionKey);
const fieldsByKey = new Map(
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
);
return (
<Card withBorder key={sectionKey} padding="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm" tt="capitalize">
{sectionConfig
? localized(sectionConfig.title)
: sectionKey.replace(/([A-Z])/g, ' $1')}
</Text>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={Boolean(flags[sectionKey])}
onChange={() => toggleFlag('FORM_SECTION', sectionKey)}
/>
</Group>
<Table withTableBorder>
<Table.Tbody>
{Object.entries(values ?? {}).map(([k, v]) => {
const fieldConfig = fieldsByKey.get(k);
return (
<Table.Tr key={k}>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{fieldConfig ? localized(fieldConfig.label) : k}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{v === null ? '—' : String(v)}</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
{flags[sectionKey] && (
<TextInput
mt="xs"
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
// Flagging without saying why is what the applicant
// would receive: "fix this section", and nothing else.
error={
flags[sectionKey].remark.trim()
? null
: t(
'review.correctionRequired',
'Say what must be corrected',
)
}
value={flags[sectionKey].remark}
onChange={(e) => {
// Read here, not inside the updater: React nulls
// `currentTarget` when the handler returns, and the
// updater runs afterwards during the re-render —
// which crashed the page on the first keystroke.
const remark = e.currentTarget.value;
setFlags((p) => ({
...p,
[sectionKey]: { ...p[sectionKey], remark },
}));
}}
/>
)}
</Card>
);
})}
</Stack>
<FormDetailsTab
formData={app.formData ?? {}}
configSections={configSections}
currency={app.feeCurrency ?? undefined}
flags={flags}
onToggleFlag={(sectionKey) => toggleFlag('FORM_SECTION', sectionKey)}
onFlagRemark={(sectionKey, remark) =>
setFlags((p) => ({
...p,
[sectionKey]: { ...p[sectionKey], remark },
}))
}
resolveLocation={resolveLocation}
/>
</Tabs.Panel>
<Tabs.Panel value="financials">
@@ -905,7 +910,11 @@ export function LicenseReviewPage() {
<DecisionConfirmModal
action={pendingAction}
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
applicantName={
app.companyName ||
applicantFullName ||
t('review.theApplicant', 'the applicant')
}
applicationNumber={app.applicationNumber}
flaggedItems={flaggedItems}
officers={officers}
@@ -916,7 +925,11 @@ export function LicenseReviewPage() {
<ScheduleExamModal
opened={scheduleExamOpen}
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
applicantName={
app.companyName ||
applicantFullName ||
t('review.theApplicant', 'the applicant')
}
loading={schedulingExam}
onClose={() => setScheduleExamOpen(false)}
onConfirm={async (payload) => {

View File

@@ -6,7 +6,11 @@ import {
verifyOtpIfPrompted,
} from './support/applicant';
import { deleteApplicant, sql, sqlValue } from './support/db';
import { approveRegistration, runWorkflow } from './support/workflow';
import {
approveRegistration,
resolveOpenRemarks,
runWorkflow,
} from './support/workflow';
/**
* Seafarer registration, applicant through to approval.
@@ -35,13 +39,20 @@ import { approveRegistration, runWorkflow } from './support/workflow';
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
* field lives where.
*/
async function completeProfile(page: Page): Promise<void> {
async function completeProfile(
page: Page,
applicant: Applicant,
): Promise<void> {
await page.goto('/profile');
await openTab(page, 'Profile');
await page.getByLabel('First Name').fill('Dawit');
await page.getByLabel('Middle Name').fill('Bekele');
await page.getByLabel('Last Name').fill('Tesfaye');
// The account's own name parts, not invented ones: the Maritime tab refuses
// to save when they do not join to the name on the Personal tab, and it
// refuses by returning early — no request, no field error, so the failure
// surfaced only as "save produced no request".
await page.getByLabel('First Name').fill(applicant.firstName);
await page.getByLabel('Middle Name').fill(applicant.middleName);
await page.getByLabel('Last Name').fill(applicant.lastName);
await pick(page, 'Gender', /male/i);
await pickDate(page, 'Date of Birth', '1995-04-12');
await pick(page, 'Marital Status', /single/i);
@@ -49,15 +60,16 @@ async function completeProfile(page: Page): Promise<void> {
await save(page);
await openTab(page, 'Address');
await pick(page, 'ID Type', /^NID$/i);
// Matched on the option's label, not its stored value: the select shows
// "National Id" and submits `NID`, so `/^NID$/` matched no option at all.
await pick(page, 'ID Type', /^national id$/i);
await page.getByLabel('ID Number').fill('FYD1234567890');
// A country select, not a free-text field.
await pick(page, 'Nationality', /ethiopia/i);
// `addressSchema` requires this in Ethiopian format; without it the form
// never submits and no request is made for `save` to wait on.
await page
.getByRole('textbox', { name: 'Primary Phone' })
.fill('+251911234567');
// Primary Phone is deliberately not filled: it is `readOnly` here and already
// carries the account's number ("From your account, edit it in the Personal
// tab"), so `addressSchema`'s Ethiopian-format rule is already satisfied and a
// fill would only fail against a read-only input.
await save(page);
}
@@ -145,19 +157,35 @@ async function save(page: Page): Promise<void> {
// A zod-blocked submit fires no request at all, so the bare timeout says
// only "no response" — which reads as a backend fault rather than a form
// that refused to submit. Surface the field errors instead.
// Field errors only. `[role="alert"]` also matches Mantine's `<Alert>`, and
// the profile page renders an informational seafarer banner as one — which
// got reported as "validation errors: Seafarer registration asks for these
// details…", pointing at a form that was in fact filled in correctly.
const messages = await page
.locator('.mantine-InputWrapper-error, [role="alert"]')
.locator('.mantine-InputWrapper-error')
.allTextContents();
throw new Error(
messages.length
? `Save did not submit — validation errors: ${messages.join('; ')}`
: 'Save produced no request and reported no validation error.',
: // No field error either, so the form was valid and something else
// refused: `onSaveProfile` early-returns when the profile name does
// not match the account name, and notifies rather than marking a
// field.
'Save produced no request and reported no field error — check for a rejected notification (e.g. the profile/account name match).',
{ cause },
);
}
}
/** Signs up, declares seafarer operations, and fills the gating profile. */
/**
* Signs up, declares seafarer operations, and fills the profile.
*
* Declaring seafarer now lands on the registration wizard, not `/profile` — the
* wizard collects the identity itself. The profile is still filled here because
* these tests are about the registration workflow, and a profile with a name and
* an address is what the approval's completion effect writes onto; `/profile` is
* navigated to directly rather than waited for as a redirect.
*/
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
const offset = await signUp(page, applicant);
await verifyOtpIfPrompted(page, offset);
@@ -167,10 +195,12 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
.first()
.check();
await page.getByRole('button', { name: /save operations/i }).click();
// A seafarer is taken to `/profile`, not the dashboard: registration is
// built from the profile, and a fresh signup holds none of it yet.
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
timeout: 30_000,
});
await page.goto('/profile');
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
await completeProfile(page);
await completeProfile(page, applicant);
}
test.describe('seafarer registration', () => {
@@ -229,7 +259,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [{ path: 'claim' }]);
expect(statusOf(number)).toBe('UNDER_REVIEW');
@@ -253,19 +283,36 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [
{ path: 'claim' },
{
path: 'request-adjustment',
data: { remarks: [{ message: 'Medical certificate is illegible.' }] },
// `RequestAdjustmentDto` takes `items`, each naming what to fix and
// where — a bare `remarks: [{ message }]` is refused with "items should
// not be empty", which reads as an empty request rather than a wrongly
// shaped one.
data: {
items: [
{
targetType: 'FORM_SECTION',
targetKey: 'medicalCertificate',
remark: 'Medical certificate is illegible.',
},
],
},
},
]);
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
// Every flagged item has to be ticked off first: `resubmit` refuses while
// any remark is open (`unresolved_remarks`), which is what stops an
// applicant returning the same form untouched.
await resolveOpenRemarks(id, openRemarkIds(number), applicant);
// A resubmission returns to review directly — a registration has no
// earlier stage to fall back to.
await runWorkflow(id, [{ path: 'resubmit' }]);
await runWorkflow(id, [{ path: 'resubmit' }], applicant);
expect(statusOf(number)).toBe('UNDER_REVIEW');
});
@@ -275,7 +322,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [
{ path: 'claim' },
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
@@ -293,7 +340,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [
{ path: 'claim' },
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
@@ -314,7 +361,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await approveRegistration(id);
expect(statusOf(number)).toBe('COMPLETED');
@@ -349,7 +396,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await approveRegistration(id);
const first = seafarerNumberOf(applicant.email);
@@ -367,7 +414,7 @@ test.describe('seafarer registration', () => {
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
await submit(idOf(number));
await submit(idOf(number), applicant);
await approveRegistration(idOf(number));
// The number is permanent and the service is not renewable, so the portal
@@ -438,6 +485,17 @@ function seafarerNumberOf(email: string): string | null {
`);
}
/** Ids of the remarks still open on the current adjustment round. */
function openRemarkIds(applicationNumber: string): string[] {
return sql(`
SELECT r.id FROM application_remarks r
JOIN license_applications a ON a.id = r.application_id
WHERE a.application_number = '${applicationNumber}'
AND r.is_resolved = false
AND r.round_number = a.adjustment_round
`).map((row) => row[0]);
}
function childrenOf(applicationNumber: string): string[][] {
return sql(`
SELECT lt.key, a.status, a.origin
@@ -452,12 +510,98 @@ function childrenOf(applicationNumber: string): string[][] {
}
/**
* Submits the draft.
* Fills the draft's answers and evidence directly, so it can be submitted.
*
* The wizard's own sections are not filled in: what these tests are about is
* the workflow and its approval effects, and a form-validation failure would
* fail them for the wrong reason. Field-level rules belong in their own spec.
* These tests are about the workflow and its approval effects, not the wizard's
* fields — but `submit` validates the whole form and every required document, so
* an unfilled draft cannot reach the workflow at all. Driving six wizard steps
* and four uploads in each test would make them slow tests of the form instead.
*
* So the answers go in as one `form_data` write and the evidence as attachment
* rows. Deliberately not through MinIO: `getSuppliedDocumentKeys` joins
* attachments to their files and counts document keys, and nothing at submission
* reads a file's bytes — a row with a storage key is exactly as complete as an
* upload, without requiring object storage to be reachable.
*
* Values mirror the seeded schema (`seafarer-registration.seed-data.ts`); a
* required field added there fails these with `application_incomplete`, naming
* the field.
*/
async function submit(applicationId: string): Promise<void> {
await runWorkflow(applicationId, [{ path: 'submit' }]);
function fillForSubmission(applicationId: string): void {
const locationId = sqlValue(`
SELECT l.id FROM iam.locations l
JOIN iam.location_types lt ON lt.id = l.location_type_id
WHERE lt.code = 'SUBCITY' LIMIT 1
`);
if (!locationId) {
throw new Error('No SUBCITY location seeded — run the location seed.');
}
const formData = JSON.stringify({
profileSummary: {
firstName: 'Dawit',
middleName: 'Bekele',
lastName: 'Tesfaye',
gender: 'MALE',
dateOfBirth: '1995-04-12',
maritalStatus: 'SINGLE',
nationality: 'Ethiopian',
nationalIdNumber: 'FYD1234567890',
},
identity: { placeOfBirth: 'Addis Ababa', department: 'DECK' },
address: { locationId, permanentAddress: 'Bole, Addis Ababa' },
emergencyContact: {
name: 'Almaz Tesfaye',
relationship: 'Sister',
phoneNumber: '+251911222333',
},
physicalCharacteristics: {
hairColor: 'BLACK',
eyeColor: 'BROWN',
heightCm: 172,
weightKg: 68,
bloodType: 'O_POSITIVE',
},
medicalCertificate: {
certificateNumber: 'MED-2026-001',
issuerName: 'Addis Marine Clinic',
issueDate: '2026-01-15',
},
declaration: { accepted: true },
}).replace(/'/g, "''");
const documentKeys = [
'photo',
'nationalId',
'medical_certificate',
'basic_training_evidence',
];
sql(`
UPDATE license_applications
SET form_data = '${formData}'::jsonb
WHERE id = '${applicationId}';
WITH inserted AS (
INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to)
SELECT 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
RETURNING id
)
INSERT INTO attachment_files
(attachment_id, original_name, mime_type, size_bytes, storage_key)
SELECT id, 'evidence.pdf', 'application/pdf', 1024, 'e2e/' || id || '.pdf'
FROM inserted;
`);
}
/** Fills what submission requires, then submits as the applicant. */
async function submit(
applicationId: string,
applicant: Applicant,
): Promise<void> {
fillForSubmission(applicationId);
// As the applicant: `submit` is ownership-guarded, so the officer's token —
// which every other step here uses — is refused with `not_application_owner`.
await runWorkflow(applicationId, [{ path: 'submit' }], applicant);
}

View File

@@ -17,7 +17,11 @@ import { E2E } from '../../playwright.config';
const OTP_PATTERN = /is (\d{4,8})\./g;
/** Byte offset to read from later. Zero when the log does not exist yet. */
/**
* Byte offset to read from later. Zero when the log does not exist yet.
*
* Bytes, and read back as bytes — see `otpSince`.
*/
export function logOffset(): number {
try {
return statSync(E2E.apiLog).size;
@@ -48,7 +52,14 @@ export async function waitForOtp(
function otpSince(offset: number): string | null {
let text: string;
try {
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
// Sliced as a Buffer, then decoded — not `readFileSync(…, 'utf8').slice()`.
// `logOffset()` is a byte count from `statSync`, while slicing a string
// counts UTF-16 code units, and the API logs Amharic notification bodies:
// every multi-byte character made the offset overshoot, so a code written
// just after it was skipped and the wait timed out. The drift grows with
// the log, which is why this failed intermittently and more often later in
// a run.
text = readFileSync(E2E.apiLog).subarray(offset).toString('utf8');
} catch {
return null;
}

View File

@@ -14,18 +14,35 @@ export interface Applicant {
username: string;
phoneNumber: string;
password: string;
/** The account name, as typed at signup. Always `${firstName} ${middleName} ${lastName}`. */
name: string;
firstName: string;
middleName: string;
lastName: string;
}
export function newApplicant(label: string): Applicant {
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
// The profile's Maritime tab refuses to save unless first/middle/last join to
// exactly the account name (`ProfilePage.onSaveProfile`) — and that refusal is
// a silent early return, no request. So the parts are the source of truth here
// and the account name is composed from them, rather than the two being
// written independently and hoped to agree.
//
// Each part is at least three characters, which `profileSchema` requires.
const firstName = 'Dawit';
const middleName = 'Bekele';
const lastName = `Tesfaye${stamp.slice(-4)}`;
return {
email: `e2e.${label}.${stamp}@example.test`,
username: `e2e${label}${stamp}`.slice(0, 28),
// Ethiopian mobile format; the last digits vary so two runs never collide.
phoneNumber: `+2519${stamp.slice(-8)}`,
password: 'E2ePassw0rd!',
name: `E2E ${label} ${stamp.slice(-4)}`,
name: `${firstName} ${middleName} ${lastName}`,
firstName,
middleName,
lastName,
};
}

View File

@@ -18,14 +18,47 @@ import { OFFICER } from './officer';
/** Routes served by the applicant-facing controller rather than the review one. */
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
async function officerContext(): Promise<APIRequestContext> {
const context = await request.newContext({ baseURL: E2E.apiUrl });
const response = await context.post('/auth/login', {
data: { email: OFFICER.email, password: OFFICER.password },
/**
* Resolves every open remark on an application, as the applicant.
*
* `resubmit` refuses while any remain (`unresolved_remarks`) — the applicant is
* expected to tick off each correction as they make it, which the portal does
* per section. A test that only wants the round-trip still has to do it.
*/
export async function resolveOpenRemarks(
applicationId: string,
remarkIds: string[],
applicant: { email: string; password: string },
): Promise<void> {
await runWorkflow(
applicationId,
remarkIds.map((remarkId) => ({
path: `remarks/${remarkId}/resolve`,
method: 'patch' as const,
})),
applicant,
);
}
/**
* An authenticated API context for one account.
*
* Paths built against it are relative on purpose. `E2E.apiUrl` carries the
* `/api` prefix, and a leading slash resolves against the *origin* —
* `/auth/login` against `http://host/api` requests `http://host/auth/login`,
* which 404s. Every path in this file is therefore written without one.
*/
async function contextFor(
who: string,
credentials: { email: string; password: string },
): Promise<APIRequestContext> {
const context = await request.newContext({ baseURL: `${E2E.apiUrl}/` });
const response = await context.post('auth/login', {
data: { email: credentials.email, password: credentials.password },
});
if (!response.ok()) {
throw new Error(
`Officer login failed (${response.status()}): ${await response.text()}`,
`${who} login failed (${response.status()}): ${await response.text()}`,
);
}
const body = await response.json();
@@ -36,17 +69,23 @@ async function officerContext(): Promise<APIRequestContext> {
await context.dispose();
return request.newContext({
baseURL: E2E.apiUrl,
baseURL: `${E2E.apiUrl}/`,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
function officerContext(): Promise<APIRequestContext> {
return contextFor('Officer', OFFICER);
}
export interface WorkflowStep {
/** Route under the review controller, e.g. `claim`, `final-approve`. */
path: string;
data?: Record<string, unknown>;
/** Set when a step is expected to be refused — the refusal is the assertion. */
expectFailure?: boolean;
/** POST unless stated; the applicant's remark-resolve route is a PATCH. */
method?: 'post' | 'patch';
}
/**
@@ -59,8 +98,29 @@ export interface WorkflowStep {
export async function runWorkflow(
applicationId: string,
steps: WorkflowStep[],
/**
* The owner, required only when a step is applicant-side. `submit` and
* `resubmit` are guarded by ownership, not permission — the officer holds
* every permission but is not the applicant, so running them on the officer's
* token is refused with `not_application_owner`.
*/
applicant?: { email: string; password: string },
): Promise<number[]> {
const api = await officerContext();
const officer = await officerContext();
const needsApplicant = steps.some(
(step) => APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'),
);
if (needsApplicant && !applicant) {
throw new Error(
`Steps [${steps
.filter((s) => APPLICANT_STEPS.has(s.path) || s.path.startsWith('remarks/'))
.map((s) => s.path)
.join(', ')}] act as the applicant — pass their credentials to runWorkflow.`,
);
}
const owner = needsApplicant && applicant
? await contextFor('Applicant', applicant)
: null;
const codes: number[] = [];
try {
@@ -68,13 +128,17 @@ export async function runWorkflow(
// Applicant-side actions (`submit`, `resubmit`) live on the
// applications controller; everything an officer does is on the review
// controller. Routing by step keeps callers from having to know.
const base = APPLICANT_STEPS.has(step.path)
const isApplicantStep =
APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/');
const base = isApplicantStep
? 'license-applications'
: 'license-application-review';
const response = await api.post(
`/${base}/${applicationId}/${step.path}`,
{ data: step.data ?? {} },
);
const api = isApplicantStep && owner ? owner : officer;
const url = `${base}/${applicationId}/${step.path}`;
const response =
step.method === 'patch'
? await api.patch(url, { data: step.data ?? {} })
: await api.post(url, { data: step.data ?? {} });
codes.push(response.status());
if (!step.expectFailure && !response.ok()) {
@@ -84,7 +148,8 @@ export async function runWorkflow(
}
}
} finally {
await api.dispose();
await officer.dispose();
await owner?.dispose();
}
return codes;

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 */}