fix(portal,backoffice): six reported issues in registration and the designer

Seafarer and vessel registration were filtered out of the operations
step by `requiresOperatorMode !== false`, so someone registering as a
seafarer or vessel owner landed on an onboarding screen that did not
describe them. Both now appear, grouped apart from the company modes:
declaring "I am a seafarer" is a different kind of statement from "my
company forwards freight".

The designer's preview was gated on the Handlebars source alone, which a
canvas layout does not have until the server compiles it on save -- so
the button was dead for exactly the designs the canvas exists for.

Editing looked broken rather than deliberately read-only: every seeded
template is PUBLISHED, and a published design is immutable because
certificates were issued from it. Says so, and offers the new-version
action that is the way forward.

Reviewing officers saw company, capital and staff tabs on seafarer
certificate applications, because PRESENTATION is keyed by the generic
licence keys and the fifty-odd rank-specific CoC/CoP keys fell through
to the company default. Matched by prefix instead, so a certificate
configured tomorrow gets the right presentation without a code change.

Seaman book and BTC are wired to the newly seeded licence types and no
longer marked "soon".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-16 22:21:00 +03:00
parent c58a727a96
commit f294f67ced
22 changed files with 1237 additions and 86 deletions

View File

@@ -15,10 +15,12 @@ import {
Tooltip,
} from '@mantine/core';
import {
IconAnchor,
IconArrowRight,
IconBuildingWarehouse,
IconChevronRight,
IconFileText,
IconShieldOff,
IconShip,
IconTrendingUp,
} from '@tabler/icons-react';
@@ -42,9 +44,12 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
// Filtered out of this catalogue (requiresOperatorMode is false), listed
// only so the record stays total if that ever changes.
// The three below are filtered out of this catalogue today
// (requiresOperatorMode is false for all of them), and are listed only so
// the record stays total if that ever changes.
MARITIME_PERSONNEL: IconShip,
VESSEL_SERVICES: IconAnchor,
WAIVER_SERVICES: IconShieldOff,
};
function formatFee(amount: string | number | null, currency: string): string {

View File

@@ -22,6 +22,18 @@ import {
import { notify, ModalFooter } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
/**
* Registrations an applicant makes for themselves rather than for a company.
*
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
* because that flag is also false for things nobody declares up front — a
* waiver is requested per shipment, not adopted as an identity.
*/
const PERSONAL_REGISTRATION_KEYS = [
'SEAFARER_REGISTRATION',
'VESSEL_REGISTRATION',
];
/**
* The applicant's modes of operation — what they do, and therefore which
* licences the portal offers them.
@@ -54,14 +66,35 @@ export function OperationsFormContent({
// the form reflects what was actually stored rather than what was typed.
useEffect(() => setSelected(declaredIds), [declaredIds]);
/**
* The operator licences, plus the two registrations an applicant declares
* for themselves.
*
* Seafarer and vessel registration are not company modes of operation, so
* they carry `requiresOperatorMode: false` and were filtered out here. But
* this screen is also where a new applicant says what they are, and someone
* registering as a seafarer or a vessel owner had no way to say so — they
* landed on an onboarding step that did not describe them.
*
* Listed separately below rather than mixed in, because declaring "I am a
* seafarer" is a different kind of statement from "my company forwards
* freight".
*/
const { operatorOptions, personalOptions } = useMemo(() => {
const active = (catalogue?.items ?? []).filter((t) => t.isActive);
return {
operatorOptions: active.filter((t) => t.requiresOperatorMode !== false),
personalOptions: active.filter(
(t) =>
t.requiresOperatorMode === false &&
PERSONAL_REGISTRATION_KEYS.includes(t.key),
),
};
}, [catalogue]);
const options = useMemo(
() =>
(catalogue?.items ?? [])
.filter((t) => t.isActive)
// A mode of operation is an operator licence; person-centric
// registrations (seafarer) cannot be declared as one.
.filter((t) => t.requiresOperatorMode !== false),
[catalogue],
() => [...operatorOptions, ...personalOptions],
[operatorOptions, personalOptions],
);
const showDate = useDateDisplayer();
@@ -110,7 +143,7 @@ export function OperationsFormContent({
<Checkbox.Group value={selected} onChange={setSelected}>
<Stack gap="sm">
{options.map((type) => (
{operatorOptions.map((type) => (
<Checkbox
key={type.id}
value={type.id}
@@ -129,6 +162,37 @@ export function OperationsFormContent({
}
/>
))}
{/* Kept apart from the company modes above: declaring "I am a
seafarer" is a different kind of statement from "my company
forwards freight", and running them together reads as though
one person could be both at once. */}
{personalOptions.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
Registering as an individual or vessel owner
</Text>
{personalOptions.map((type) => (
<Checkbox
key={type.id}
value={type.id}
label={
<Group gap="xs" wrap="nowrap">
<Text size="sm">{localized(type.name)}</Text>
{declaredIds.includes(type.id) && (
<Badge size="xs" variant="light" color="teal">
Current
</Badge>
)}
</Group>
}
description={
type.description ? localized(type.description) : undefined
}
/>
))}
</>
)}
</Stack>
</Checkbox.Group>

View File

@@ -1,21 +1,8 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { Navigate } from 'react-router-dom';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
/** The apply route forwards into the shared licensing wizard. */
export function SeamanBookApplicationPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Apply for a Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
return <Navigate to="/licensing/SEAMAN_BOOK/apply" replace />;
}
export default SeamanBookApplicationPage;

View File

@@ -1,21 +1,12 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { Navigate } from 'react-router-dom';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
* The seaman book rides the config-driven licensing flow, like every other
* issued document, so this route forwards to it rather than duplicating the
* wizard. Kept as a route because the nav and older links point here.
*/
export function SeamanBookPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
return <Navigate to="/licensing/SEAMAN_BOOK/apply" replace />;
}
export default SeamanBookPage;

View File

@@ -55,6 +55,8 @@ export const am: Translations = {
seaRecords: 'የባህር መዝገቦቼ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ',
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
endorsements: 'ማረጋገጫዎች',
vesselRegistrations: 'የመርከብ ምዝገባ',
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',

View File

@@ -54,6 +54,8 @@ export const en = {
mtoLicense: 'MTO License',
waiver: 'Waiver',
certificates: 'Certificates',
seamanBook: 'Seaman Book',
btc: 'Basic Training Certificate',
endorsements: 'Endorsements',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Transfers',

View File

@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
import {
IconArrowsExchange,
IconBell,
IconBook2,
IconFolderOpen,
IconHeadset,
IconHome2,
@@ -76,7 +77,8 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
items: [
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] },
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.seamanBook', icon: IconBook2, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/licensing/BTC_BASIC_TRAINING/apply', label: 'Basic Training Certificate', i18nKey: 'nav.btc', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },