Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
nati14575
2026-08-19 16:49:12 +03:00
19 changed files with 818 additions and 189 deletions

View File

@@ -1,5 +1,4 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
/**
* Where an action is rendered. One tier per action, decided here rather than
@@ -31,10 +30,11 @@ export type ActionId =
| 'reject'
| 'schedule-exam'
| 'confirm-payment'
| 'schedule-issuance'
| 'issue-certificate'
| 'print'
| 'copy-link'
| 'download-documents'
| 'generate-certificate'
| 'audit-trail';
export interface ActionDefinition {
@@ -211,6 +211,28 @@ export const ACTIONS: ActionDefinition[] = [
emphasis: 'filled',
color: 'teal',
},
{
id: 'schedule-issuance',
tier: 'primary',
labelKey: 'review.actions.scheduleIssuance',
// Only reachable for a license type with `requiresIssuanceScheduling` —
// everything else cascades straight to CERTIFICATE_ISSUED and never
// shows PAYMENT_CONFIRMED with this action available (the server's
// `availableEvents` omits it there, same as the rest of this list).
from: ['PAYMENT_CONFIRMED'],
permissions: ['can:schedule:license-issuance'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'issue-certificate',
tier: 'primary',
labelKey: 'review.actions.issueCertificate',
from: ['SCHEDULED'],
permissions: ['can:issue:license-certificate'],
emphasis: 'filled',
color: 'teal',
},
// ------------------------------------------------------------ secondary
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
@@ -220,13 +242,6 @@ export const ACTIONS: ActionDefinition[] = [
tier: 'secondary',
labelKey: 'review.actions.downloadDocuments',
},
{
id: 'generate-certificate',
tier: 'secondary',
labelKey: 'review.actions.generateCertificate',
from: ['CERTIFICATE_ISSUED'],
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
},
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
];
@@ -287,6 +302,8 @@ const WORKFLOW_EVENT_IDS = new Set<ActionId>([
'request-adjustment',
'reject',
'confirm-payment',
'schedule-issuance',
'issue-certificate',
]);
/**

View File

@@ -10,6 +10,8 @@ export function licenseQueueActionsColumn(
claiming: boolean;
onClaim: (id: string) => void;
onOpen: (id: string) => void;
/** False for a non-logistics queue — there's no unclaimed pool to claim from. */
claimable?: boolean;
},
): AdvancedColumn<LicenseApplication> {
return {
@@ -18,6 +20,7 @@ export function licenseQueueActionsColumn(
align: "right",
size: 140,
cell: ({ row }) =>
handlers.claimable !== false &&
row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? (
<RequirePermission

View File

@@ -2,7 +2,6 @@ import type { Dispatch, ReactNode, SetStateAction } from "react";
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
import type { TFunction } from "i18next";
import {
APPLICANT_NAME_TYPE_KEYS,
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
@@ -14,11 +13,36 @@ import type { AdvancedColumn } from "@ema-platform/ui";
import { dateDisplayer } from "@ema-platform/shared";
import { computeSla } from "../../sla";
/**
* Label for the Company/Applicant column, derived from the rows actually on
* screen rather than the route — a type-pinned queue (`/type/:typeCode`)
* happens to be one family, but nothing stops the mixed "All Applications"
* grid from holding both, and a static header can't be correct for both at
* once. Falls back to the combined label until the page has data to look at.
*/
function companyColumnHeader(
t: TFunction,
items: LicenseApplication[],
isLogistics: boolean | undefined,
): string {
// A type-pinned non-logistics queue (Seafarer Registration, Seaman Book,
// BTC, ...) is always "Applicant" — no need to guess from loaded rows.
if (isLogistics === false) return t("queue.applicant", "Applicant");
if (isLogistics === true) return t("queue.company", "Company");
if (items.length === 0) {
return t("queue.companyOrApplicant", "Applicant / Company");
}
const allLogistics = items.every((a) => a.familyKind === "LOGISTICS_LICENSE");
const allNonLogistics = items.every((a) => a.familyKind !== "LOGISTICS_LICENSE");
if (allLogistics) return t("queue.company", "Company");
if (allNonLogistics) return t("queue.applicant", "Applicant");
return t("queue.companyOrApplicant", "Applicant / Company");
}
export function licenseQueueColumns(
t: TFunction,
locale: string,
opts: {
typeCode: string | undefined;
items: LicenseApplication[];
selected: string[];
setSelected: Dispatch<SetStateAction<string[]>>;
@@ -27,11 +51,12 @@ export function licenseQueueColumns(
label: string,
field: NonNullable<QueueFilter["sortBy"]>,
) => ReactNode;
/** Set for a type-pinned queue; undefined for the mixed All/Mine grids. */
isLogistics?: boolean;
},
): AdvancedColumn<LicenseApplication>[] {
const { typeCode, items, selected, setSelected, allSelected, sortableHeader } =
opts;
return [
const { items, selected, setSelected, allSelected, sortableHeader, isLogistics } = opts;
const columns: AdvancedColumn<LicenseApplication>[] = [
{
header: (
<Checkbox
@@ -72,25 +97,18 @@ export function licenseQueueColumns(
),
},
{
header: sortableHeader(
typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode)
? t("queue.applicant", "Applicant")
: t("queue.company", "Company"),
"companyName",
),
// Header reflects what's actually on screen, not the route: a
// type-pinned queue (`typeCode` set) is always one family, but the
// mixed "All Applications" grid can hold logistics rows and
// certificate/document rows side by side, so no single static label is
// right for the whole column there — "Applicant / Company" covers
// both without claiming a row is one or the other.
header: sortableHeader(companyColumnHeader(t, items, isLogistics), "companyName"),
label: t("queue.company", "Company"),
cell: ({ row }) => (
<Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text>
),
},
{
header: t("queue.tin", "TIN"),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? "—"}
</Text>
),
},
{
header: t("queue.typeCol", "Type"),
cell: ({ row }) => (
@@ -144,4 +162,21 @@ export function licenseQueueColumns(
},
},
];
// A type-pinned non-logistics queue never has a TIN to show — a business
// registration number doesn't apply to a certificate/document filed by a
// person — so the column itself is dropped rather than left showing blanks.
if (isLogistics !== false) {
columns.splice(2, 0, {
header: t("queue.tin", "TIN"),
cell: ({ row }) =>
row.original.familyKind === "LOGISTICS_LICENSE" ? (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? "—"}
</Text>
) : null,
});
}
return columns;
}

View File

@@ -33,7 +33,9 @@ import { useTranslation } from "react-i18next";
import {
STATUS_LABELS,
extractErrorMessage,
familyLabels,
localized,
resolveFamilyKind,
useClaimApplicationMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
@@ -59,6 +61,7 @@ import {
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
savedViewsForFamily,
searchParamsFromFilter,
writeLastView,
type SavedViewId,
@@ -135,10 +138,19 @@ export function LicenseQueuePage() {
const dispatch = useAppDispatch();
const density = useAppSelector((state) => state.preferences.density);
// Type-pinned queues resolve a family straight from the URL, no query
// needed — `resolveFamilyKind` falls back to LOGISTICS_LICENSE for unknown
// keys and undefined for the mixed All/Mine grids, which is the safe
// default (nothing hidden) in both cases.
const isLogistics = typeCode
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
: undefined;
const visibleViews = savedViewsForFamily(isLogistics !== false);
const [view, setView] = useState<SavedViewId>(
() =>
(searchParams.get("view") as SavedViewId) ||
(typeCode === "BTC_BASIC_TRAINING" ? "all" : readLastView()),
(isLogistics === false ? "all" : readLastView()),
);
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
@@ -148,18 +160,21 @@ export function LicenseQueuePage() {
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Auto-created BTC requests start at PAYMENT_PENDING, which is not part of
// the unassigned officer work pool. A dedicated BTC Queue must therefore
// open its all-status view so those requests are visible immediately.
// Non-logistics queues have no unassigned/unclaimed pool (see
// `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from
// `readLastView()` — must fall back to "all" rather than land on a tab that
// no longer exists. Auto-created BTC requests specifically start at
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
// to show them.
useEffect(() => {
if (
typeCode === "BTC_BASIC_TRAINING" &&
isLogistics === false &&
!searchParams.has("view") &&
view !== "all"
view === "unassigned"
) {
setView("all");
}
}, [typeCode, searchParams, view]);
}, [isLogistics, searchParams, view]);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
@@ -385,15 +400,28 @@ export function LicenseQueuePage() {
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
// rather than an error the officer has to read.
if (cursorRow && cursorRow.assignedOfficerId === null)
// Only unclaimed rows on a logistics queue can be claimed; pressing c
// elsewhere is a no-op rather than an error the officer has to read.
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
onHelp: () => setHelpOpen(true),
});
// Deep-linked by type (`/licence-review/type/:typeCode`), so the queue
// title/labels read "Certificate applications" for a CoC queue and
// "Document applications" for a Seaman Book queue rather than always
// "Licence applications" — the All/Mine views have no single type and stay
// on the licence-flavoured default, matching today's behaviour.
const queueLabels = familyLabels(resolveFamilyKind(typeCode));
const queueTitle = typeCode
? t("queue.titleByFamily", {
family: queueLabels.typeLabel,
defaultValue: `${queueLabels.typeLabel} applications`,
})
: t("queue.title", "Licence applications");
const allSelected = items.length > 0 && selected.length === items.length;
const sortIcon =
urlFilter.sortDir === "DESC" ? (
@@ -428,17 +456,20 @@ export function LicenseQueuePage() {
const columns: AdvancedColumn<LicenseApplication>[] = useMemo(
() => [
...licenseQueueColumns(t, i18n.language, {
typeCode,
items,
selected,
setSelected,
allSelected,
sortableHeader,
isLogistics,
}),
licenseQueueActionsColumn(t, {
claiming,
onClaim: handleClaim,
onOpen: (id) => navigate(`/licence-review/${id}`),
// Non-logistics applications aren't claimed off a shared queue (see
// `savedViewsForFamily`) — every row opens straight to Review.
claimable: isLogistics !== false,
}),
],
[
@@ -450,7 +481,7 @@ export function LicenseQueuePage() {
allSelected,
items,
claiming,
typeCode,
isLogistics,
],
);
@@ -458,7 +489,7 @@ export function LicenseQueuePage() {
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{t("queue.title", "Licence applications")}</Title>
<Title order={3}>{queueTitle}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
@@ -499,7 +530,7 @@ export function LicenseQueuePage() {
mb="sm"
>
<Tabs.List>
{SAVED_VIEWS.map((savedView) => (
{visibleViews.map((savedView) => (
<Tabs.Tab
key={savedView.id}
value={savedView.id}
@@ -542,7 +573,7 @@ export function LicenseQueuePage() {
/>
{!typeCode && (
<Select
label={t("queue.type", "Licence type")}
label={t("queue.type", "Type")}
placeholder={t("queue.anyType", "Any")}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
@@ -645,7 +676,7 @@ export function LicenseQueuePage() {
<AdvancedTable
columns={columns}
data={items}
tableName={t("queue.title", "Licence applications")}
tableName={queueTitle}
itemCount={total}
pageIndex={page - 1}
onPageChange={(pageIndex) => {
@@ -722,17 +753,19 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
{isLogistics !== false && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
)}
</Group>
</Group>
</Paper>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
@@ -41,6 +41,8 @@ import {
useAssignApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleExamMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
@@ -147,6 +149,7 @@ function buildChecklist(
* position instead of scrolling back to a column of buttons.
*/
export function LicenseReviewPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const showDate = useDateDisplayer();
const localized = useLocalized();
@@ -202,6 +205,8 @@ export function LicenseReviewPage() {
const [scheduleInspection] = useScheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
const [issueCertificate] = useIssueCertificateMutation();
const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation();
const [holdApplication] = useHoldApplicationMutation();
@@ -225,6 +230,8 @@ export function LicenseReviewPage() {
const [railOpen, setRailOpen] = useState(true);
const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [findings, setFindings] = useState("");
@@ -472,6 +479,9 @@ export function LicenseReviewPage() {
case "schedule-inspection":
setInspectionOpen(true);
return;
case "schedule-issuance":
setIssuanceOpen(true);
return;
case "record-inspection":
setResultOpen(true);
return;
@@ -615,6 +625,12 @@ export function LicenseReviewPage() {
t("review.done.confirmPayment", "Payment confirmed"),
);
break;
case "issue-certificate":
await run(
() => issueCertificate(id).unwrap(),
t("review.done.issueCertificate", "Certificate issued"),
);
break;
case "hold":
await run(
() => holdApplication({ id, reason: submission.reason }).unwrap(),
@@ -688,6 +704,12 @@ export function LicenseReviewPage() {
applicantFullName ||
applicantOrCompanyName(app) ||
app.applicationNumber;
const linkedBookServices = (data.relatedApplications ?? []).filter(
(related) =>
["SEAMAN_BOOK", "BTC_BASIC_TRAINING"].includes(
related.licenseType?.key ?? "",
),
);
return (
<Container size="xl" py="md">
@@ -710,6 +732,25 @@ export function LicenseReviewPage() {
</Badge>
)}
</Group>
{linkedBookServices.length > 1 && (
<Group gap="xs" mt="xs">
{linkedBookServices.map((related) => (
<Badge
key={related.id}
variant={related.id === app.id ? "filled" : "outline"}
color={STATUS_COLORS[related.status]}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/licence-review/${related.id}`)}
>
{related.licenseType?.key === "SEAMAN_BOOK"
? "Seaman Book"
: "BTC"}{" "}
· {related.applicationNumber} ·{" "}
{STATUS_LABELS[related.status]}
</Badge>
))}
</Group>
)}
</div>
<Group gap="xs">
<Tooltip
@@ -1177,6 +1218,50 @@ export function LicenseReviewPage() {
</Stack>
</Modal>
<Modal
opened={issuanceOpen}
onClose={() => setIssuanceOpen(false)}
title={t("review.actions.scheduleIssuance", "Schedule pickup")}
>
<Stack>
<AmharicDatePicker
label={t("review.pickupDate", "Pickup date")}
value={issuanceDate}
onChange={setIssuanceDate}
/>
<ModalFooter>
<Tooltip
label={t("review.pickDate", "Pick a date and time first")}
disabled={Boolean(issuanceDate)}
>
<span>
<button type="button" hidden aria-hidden />
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>
<Modal
opened={resultOpen}
onClose={() => setResultOpen(false)}

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveFamilyKind } from "@ema-platform/api";
describe("resolveFamilyKind", () => {
it("treats person-centric seafarer applications as document queues", () => {
expect(resolveFamilyKind("SEAFARER_REGISTRATION")).toBe("DOCUMENT");
expect(resolveFamilyKind("SEAMAN_BOOK")).toBe("DOCUMENT");
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
});
});

View File

@@ -77,6 +77,17 @@ export const SAVED_VIEWS: SavedView[] = [
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
/**
* Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
* have no unclaimed pool to triage — those applications aren't claimed off a
* shared queue — so the tab that lists it doesn't apply there.
*/
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
return isLogistics
? SAVED_VIEWS
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
}
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
export function readLastView(): SavedViewId {

View File

@@ -44,9 +44,10 @@ import { paymentConfigColumns } from './columns';
import { paymentConfigActionsColumn } from './actions';
/**
* Licence fee configuration.
* Fee configuration — shared across logistics licences, seafarer
* certificates and seafarer/vessel documents alike.
*
* The amounts live on the licence type itself, which is what the workflow
* The amounts live on the license type itself, which is what the workflow
* reads when it raises a payment — so what is edited here is the same value
* the applicant is charged, not a parallel copy of it.
*
@@ -72,7 +73,7 @@ export function PaymentConfigPage() {
<Alert
color="red"
icon={<IconAlertTriangle size={18} />}
title={t('paymentConfig.loadError', 'Could not load licence types')}
title={t('paymentConfig.loadError', 'Could not load fee types')}
>
<Text size="sm">{extractErrorMessage(error)}</Text>
</Alert>

View File

@@ -55,6 +55,7 @@ export const am: Translations = {
groupSeafarer: "የመርከበኞች አገልግሎት",
groupVessels: "መርከቦች",
groupExaminations: "ፈተናዎች",
groupShared: "የጋራ አገልግሎቶች",
groupAdministration: "አስተዳደር",
groupAccount: "መለያ",
soon: "በቅርቡ",
@@ -815,11 +816,12 @@ export const am: Translations = {
queue: {
title: "የፈቃድ ማመልከቻዎች",
titleByFamily: "{{family}} ማመልከቻዎች",
search: "ፍለጋ",
searchPlaceholder: "ኩባንያ፣ ቲን ወይም ቁጥር",
status: "ሁኔታ",
anyStatus: "ማንኛውም",
type: "የፈቃድ ዓይነት",
type: "ዓይነት",
anyType: "ማንኛውም",
typeCol: "ዓይነት",
statusCol: "ሁኔታ",
@@ -854,6 +856,7 @@ export const am: Translations = {
number: "ማመልከቻ ቁ.",
company: "ኩባንያ",
applicant: "አመልካች",
companyOrApplicant: "አመልካች / ኩባንያ",
tin: "ቲን",
submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ",

View File

@@ -54,6 +54,7 @@ export const en = {
groupSeafarer: 'Seafarer Services',
groupVessels: 'Vessels',
groupExaminations: 'Examinations',
groupShared: 'Shared Services',
groupAdministration: 'Administration',
groupAccount: 'Account',
soon: 'Soon',
@@ -817,11 +818,15 @@ export const en = {
queue: {
title: 'Licence applications',
// {{family}} is "Certificate"/"Document"/"Licence" — used only on a
// type-scoped queue (`/licence-review/type/:typeCode`), where the whole
// list is one family; the mixed All/Mine views keep the plain title above.
titleByFamily: '{{family}} applications',
search: 'Search',
searchPlaceholder: 'Company, TIN or number',
status: 'Status',
anyStatus: 'Any',
type: 'Licence type',
type: 'Type',
anyType: 'Any',
typeCol: 'Type',
statusCol: 'Status',
@@ -855,6 +860,10 @@ export const en = {
number: 'App #',
company: 'Company',
applicant: 'Applicant',
// Column header when the grid holds both logistics-licence rows (which
// have a company) and certificate/document rows (which have an
// applicant instead) — the mixed "All Applications" queue.
companyOrApplicant: 'Applicant / Company',
tin: 'TIN',
submitted: 'Submitted',
sla: 'Age / SLA',

View File

@@ -37,9 +37,15 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
/**
* The backoffice information architecture.
*
* Six top-level groups, none deeper than one level of nesting. `soon` marks
* Seven top-level groups, none deeper than one level of nesting. `soon` marks
* screens with no backend behind them, so a reviewer can tell at a glance what
* actually works.
*
* `groupLicensing` is scoped strictly to logistics-operator licences (the
* permission a company holds to trade) — Certificate Designer and Payment
* Config serve every family (logistics licences, seafarer certificates,
* seafarer/vessel documents alike), so they sit in `groupShared` instead of
* implying they're licensing-only.
*/
export const NAV_SECTIONS: NavSection[] = [
{
@@ -74,12 +80,6 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconListCheck,
permissions: [P.VIEW_LICENSES],
},
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{
@@ -89,12 +89,6 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconGauge,
permissions: APPLICATION_QUEUE,
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
],
},
{
@@ -140,6 +134,23 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] },
],
},
{
label: 'nav.groupShared',
items: [
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
],
},
{
label: 'nav.groupAdministration',
items: [

View File

@@ -81,10 +81,15 @@ export function LicenseCatalogue() {
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? [])
.filter((t) => t.isActive)
// Person-centric registrations (seafarer) are not operator licences:
// they can never be declared as a mode, have their own entry points,
// and would only confuse this catalogue — even under "show all".
.filter((t) => t.requiresOperatorMode !== false)
// Logistics licences only: this is the operator catalogue, not the
// seafarer certificate or vessel/seafarer document catalogue — those
// have their own entry points. `familyKind` is the real data-model
// classification (set on the type at seed time); `requiresOperatorMode`
// was the proxy this used before that column existed and happened to
// agree for every type seeded so far, but a type can only be trusted to
// stay in sync with the catalogue it belongs in if the catalogue reads
// its actual family instead of a flag with a different purpose.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.

View File

@@ -1,5 +1,10 @@
import { useNavigate } from 'react-router-dom';
import { useApiQuery } from '@ema-platform/api';
import { useNavigate } from "react-router-dom";
import {
useApiQuery,
useBypassPaymentMutation,
useGetPaymentCapabilitiesQuery,
} from "@ema-platform/api";
import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment";
import {
Alert,
Badge,
@@ -15,7 +20,7 @@ import {
Text,
ThemeIcon,
Title,
} from '@mantine/core';
} from "@mantine/core";
import {
IconAlertCircle,
IconBook2,
@@ -28,13 +33,15 @@ import {
IconPrinter,
IconShield,
IconX,
} from '@tabler/icons-react';
} from "@tabler/icons-react";
interface ApplicationSummary {
id: string;
applicationId: string;
status: string;
submittedAt: string;
/** Set once an officer schedules the pickup date, ahead of CERTIFICATE_ISSUED. */
scheduledIssuanceDate: string | null;
}
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
@@ -71,16 +78,24 @@ interface SeamanBookOverview {
* same journey would only drift out of step with it.
*/
const STAGES: { label: string; statuses: string[] }[] = [
{ label: 'Submitted', statuses: ['SUBMITTED', 'UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] },
{ label: 'Approved', statuses: ['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED'] },
{ label: 'Issued', statuses: ['CERTIFICATE_ISSUED', 'COMPLETED'] },
{
label: "Submitted",
statuses: ["SUBMITTED", "UNDER_REVIEW", "UNDER_EVALUATION"],
},
{ label: "Under Review", statuses: ["UNDER_REVIEW", "UNDER_EVALUATION"] },
{
label: "Approved",
statuses: ["APPROVED", "PAYMENT_PENDING", "PAID", "PAYMENT_CONFIRMED"],
},
// Printed once, handed over in person — an officer sets a pickup date
// before this reaches CERTIFICATE_ISSUED.
{ label: "Pickup Scheduled", statuses: ["SCHEDULED"] },
{ label: "Issued", statuses: ["CERTIFICATE_ISSUED", "COMPLETED"] },
];
/** How far along the stepper a status sits; -1 for a draft. */
function stageIndexFor(status: string | undefined): number {
if (!status || status === 'DRAFT') return -1;
if (!status || status === "DRAFT") return -1;
let reached = -1;
STAGES.forEach((stage, i) => {
if (stage.statuses.includes(status)) reached = i;
@@ -94,38 +109,46 @@ function stageIndexFor(status: string | undefined): number {
// reads whatever the API reports, and an unmapped status falls back to grey
// rather than vanishing.
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'orange',
PAYMENT_PENDING: 'orange',
PAID: 'blue',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
DRAFT: "gray",
SUBMITTED: "blue",
UNDER_REVIEW: "yellow",
UNDER_EVALUATION: "yellow",
RESUBMIT_REQUIRED: "orange",
INSPECTION_PENDING: "grape",
INSPECTION_COMPLETED: "grape",
APPROVED: "teal",
REJECTED: "red",
ON_HOLD: "orange",
PAYMENT_PENDING: "orange",
PAID: "blue",
PAYMENT_CONFIRMED: "blue",
SCHEDULED: "grape",
CERTIFICATE_ISSUED: "teal",
COMPLETED: "teal",
};
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
return new Date(value).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
return (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
<ThemeIcon
size={22}
radius="xl"
variant={ok ? "filled" : "light"}
color={ok ? "teal" : "red"}
>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
<Text fz="sm" c={ok ? undefined : "dimmed"}>
{label}
</Text>
</Group>
);
}
@@ -164,17 +187,17 @@ function ApplicationCard({
{/* 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'}{' '}
{application.status === "DRAFT" ? "Opened" : "Submitted"}{" "}
{formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
color={STATUS_COLOR[application.status] ?? "gray"}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
{application.status.replaceAll("_", " ")}
</Badge>
</Group>
@@ -183,7 +206,7 @@ function ApplicationCard({
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
description={i <= activeStep ? "Done" : "Pending"}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
@@ -203,13 +226,39 @@ function ApplicationCard({
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SeamanBookPage() {
export function SeamanBookPage({
service = "COMBINED",
}: {
service?: "COMBINED" | "SEAMAN_BOOK" | "BTC";
}) {
const navigate = useNavigate();
const { pay, isPaying } = useApplicationPayment();
const isBtc = service === "BTC";
const isCombined = service === "COMBINED";
const { data, isLoading } = useApiQuery<SeamanBookOverview>({
url: '/seaman-book/my',
method: 'GET',
});
// Polled, not fetch-once: the officer who claims/reviews/approves this
// application (and the auto-promotion when the parent seafarer
// registration is approved) all happen in a different session, so nothing
// in this tab would otherwise tell RTK Query the status changed underneath
// it — the applicant would see a stale "Draft"/"Payment Pending" until they
// manually reloaded. `useApiQuery` is a generic untagged passthrough (many
// unrelated callers share it), so polling this one call is the fix that
// doesn't risk over-invalidating everyone else's cache.
const { data, isLoading, refetch } = useApiQuery<SeamanBookOverview>(
{
url: "/seaman-book/my",
method: "GET",
},
{ pollingInterval: 15_000 },
);
const { data: paymentCapabilities } = useGetPaymentCapabilitiesQuery();
const [bypassPayment, { isLoading: bypassingPayment }] =
useBypassPaymentMutation();
const completeTestPayment = async (applicationId: string) => {
await bypassPayment(applicationId).unwrap();
refetch();
};
const application = data?.application ?? null;
const btcApplication = data?.btcApplication ?? null;
@@ -223,35 +272,129 @@ export function SeamanBookPage() {
// 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);
const submitted = Boolean(
isCombined
? application || btcApplication
: isBtc
? btcApplication
: application,
);
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Title order={3}>
My Application {" "}
{isCombined
? "Seaman Book & Basic Training Certificate"
: isBtc
? "Basic Training Certificate"
: "Seaman Book"}
</Title>
<Text fz="sm" c="dimmed">
A Seaman Book is your official maritime identity document. It records your sea service and must be
held before joining any vessel.
{isCombined
? "Track both applications together and pay each service separately."
: isBtc
? "Track and manage your Basic Training Certificate application."
: "A Seaman Book is your official maritime identity document. It records your sea service and must be held before joining any vessel."}
</Text>
</div>
{/* Active application status — one card per service in flight. */}
{application && (
{(isCombined || !isBtc) && application && (
<ApplicationCard title="Seaman Book" application={application}>
{data?.book && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
{application.status === "PAYMENT_PENDING" && (
<Group mt="md">
<Button
loading={isPaying}
onClick={() => pay(application.applicationId)}
>
Pay now
</Button>
{paymentCapabilities?.bypassEnabled && (
<Button
variant="default"
loading={bypassingPayment}
onClick={() => completeTestPayment(application.applicationId)}
>
Complete test payment
</Button>
)}
</Group>
)}
{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.
Please visit the EMA office to collect it, bringing your National
ID.
</Alert>
) : (
application.status === "SCHEDULED" &&
application.scheduledIssuanceDate && (
<Alert
variant="light"
color="grape"
icon={<IconPrinter size={17} />}
mt="md"
>
Your Seaman Book is ready for collection on{" "}
<strong>{formatDate(application.scheduledIssuanceDate)}</strong>
. Please visit the EMA office on that date, bringing your
National ID.
</Alert>
)
)}
</ApplicationCard>
)}
{btcApplication && (
{(isCombined || isBtc) && btcApplication && (
<ApplicationCard
title="Basic Training Certificate"
application={btcApplication}
/>
>
{btcApplication.status === "PAYMENT_PENDING" && (
<Group mt="md">
<Button
loading={isPaying}
onClick={() => pay(btcApplication.applicationId)}
>
Pay now
</Button>
{paymentCapabilities?.bypassEnabled && (
<Button
variant="default"
loading={bypassingPayment}
onClick={() =>
completeTestPayment(btcApplication.applicationId)
}
>
Complete test payment
</Button>
)}
</Group>
)}
{btcApplication.status === "SCHEDULED" &&
btcApplication.scheduledIssuanceDate && (
<Alert
variant="light"
color="grape"
icon={<IconPrinter size={17} />}
mt="md"
>
Your Basic Training Certificate is ready for collection on{" "}
<strong>
{formatDate(btcApplication.scheduledIssuanceDate)}
</strong>
. Please visit the EMA office on that date, bringing your
National ID.
</Alert>
)}
</ApplicationCard>
)}
{/* No active application — eligibility + apply */}
@@ -260,7 +403,12 @@ export function SeamanBookPage() {
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
<ThemeIcon
variant="light"
color={isEligible ? "teal" : "orange"}
size={36}
radius="md"
>
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
@@ -279,7 +427,7 @@ export function SeamanBookPage() {
label={
eligibility?.medicalExpiry
? `Valid medical certificate (expires ${formatDate(eligibility.medicalExpiry)})`
: 'Valid medical certificate uploaded'
: "Valid medical certificate uploaded"
}
ok={Boolean(eligibility?.hasMedical)}
/>
@@ -290,23 +438,42 @@ export function SeamanBookPage() {
my={4}
/>
{bstItems.map((item) => (
<EligibilityItem key={item.key} label={item.label} ok={item.done} />
<EligibilityItem
key={item.key}
label={item.label}
ok={item.done}
/>
))}
{!isLoading && !isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Alert
variant="light"
color="orange"
icon={<IconAlertCircle size={15} />}
mt="xs"
p="sm"
>
<Text fz="xs">
Complete all requirements above before applying.
{bstItems.length > bstDone
? ` Missing BST: ${bstItems.length - bstDone} certificate(s).`
: ''}
: ""}
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
<Alert
variant="light"
color="teal"
icon={<IconCircleCheck size={15} />}
mt="xs"
p="sm"
>
<Text fz="xs">
You meet all requirements. You may proceed with your
application.
</Text>
</Alert>
)}
</Stack>
@@ -323,24 +490,30 @@ export function SeamanBookPage() {
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will verify your profile,
documents, medical certificate, and Basic Safety Training certificates. You will be
notified at each stage by email and SMS.
Upon submitting your application, EMA Registration Officers will
verify your profile, documents, medical certificate, and Basic
Safety Training certificates. You will be notified at each stage
by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Text fw={600} fz="sm">
What will be verified:
</Text>
<Stack gap={6}>
{[
'Full seafarer profile',
'National ID / Fayda authenticity',
'Medical certificate validity',
'All 5 Basic Safety Training certificates',
'Passport size photo',
"Full seafarer profile",
"National ID / Fayda authenticity",
"Medical certificate validity",
"All 5 Basic Safety Training certificates",
"Passport size photo",
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<IconCircleCheck
size={15}
color="var(--mantine-color-teal-6)"
/>
<Text fz="sm">{item}</Text>
</Group>
))}
@@ -353,8 +526,12 @@ export function SeamanBookPage() {
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
<Text fz="xs" c="dimmed">
Processing time
</Text>
<Text fz="sm" fw={600}>
57 working days
</Text>
</div>
</Group>
</Card>
@@ -362,22 +539,38 @@ export function SeamanBookPage() {
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
<Text fz="xs" c="dimmed">
Medical validity
</Text>
<Text fz="sm" fw={600}>
2 years (STCW)
</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={15} />}
p="xs"
>
<Text fz="xs">
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
Application fee will be communicated during the review
process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
onClick={() =>
navigate(
isBtc
? "/licensing/BTC_BASIC_TRAINING/apply"
: "/seaman-book/apply",
)
}
disabled={!isEligible}
size="md"
>
@@ -398,20 +591,62 @@ export function SeamanBookPage() {
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
<Text fw={700} fz="sm">
About the{" "}
{isCombined
? "Seaman Book & Basic Training Certificate"
: isBtc
? "Basic Training Certificate"
: "Seaman Book"}
</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
{(isBtc
? [
{
icon: IconShield,
title: "STCW Training",
desc: "Confirms completion of the required basic maritime safety training.",
},
{
icon: IconFileDescription,
title: "Certificate Record",
desc: "Keeps your approved basic training evidence available in one place.",
},
{
icon: IconCircleCheck,
title: "Verified",
desc: "Issued after EMA verifies the applicable training requirements.",
},
]
: [
{
icon: IconBook2,
title: "Official Identity",
desc: "Internationally recognized maritime identity document required before joining any vessel.",
},
{
icon: IconFileDescription,
title: "Service Record",
desc: "Records all your sea service, vessel assignments, and employment history.",
},
{
icon: IconShield,
title: "STCW Compliance",
desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.",
},
]
).map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
<Text fz="sm" fw={600}>
{title}
</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
<Text fz="xs" c="dimmed" lh={1.5}>
{desc}
</Text>
</Box>
))}
</SimpleGrid>

View File

@@ -113,7 +113,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
},
{
to: "/licensing/BTC_BASIC_TRAINING/apply",
to: "/basic-training-certificate",
label: "Basic Training Certificate",
i18nKey: "nav.btc",
icon: IconShieldCheck,
@@ -198,6 +198,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
"/seafarer/records": { i18nKey: "nav.seaRecords" },
"/seaman-book": { i18nKey: "nav.myApplication" },
"/basic-training-certificate": { i18nKey: "nav.btc" },
"/certificates": { i18nKey: "nav.certificates" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },

View File

@@ -122,6 +122,24 @@ export const router = createBrowserRouter([
{ path: "/payments/check", element: <PaymentCheckPage /> },
{ path: "/payments/success", element: <PaymentSuccessPage /> },
{ path: "/payments/failure", element: <PaymentFailurePage /> },
// Seaman Book and BTC are auto-opened together after seafarer approval.
// They use the shared status/payment page, never the generic form wizard.
{
path: "/licensing/BTC_BASIC_TRAINING/apply",
element: <Navigate to="/basic-training-certificate" replace />,
},
{
path: "/licensing/BTC_BASIC_TRAINING/applications/:applicationId",
element: <Navigate to="/basic-training-certificate" replace />,
},
{
path: "/licensing/SEAMAN_BOOK/apply",
element: <Navigate to="/seaman-book" replace />,
},
{
path: "/licensing/SEAMAN_BOOK/applications/:applicationId",
element: <Navigate to="/seaman-book" replace />,
},
{
path: "/licensing/:typeCode/apply",
element: (
@@ -169,7 +187,9 @@ export const router = createBrowserRouter([
{
path: "/seafarer/records",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<MySeaRecordsPage />
</RequirePermission>
),
@@ -198,7 +218,17 @@ export const router = createBrowserRouter([
{
path: "/seaman-book",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<SeamanBookPage />
</RequirePermission>
),
},
{
path: "/basic-training-certificate",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<SeamanBookPage />
</RequirePermission>
),
@@ -206,7 +236,9 @@ export const router = createBrowserRouter([
{
path: "/seaman-book/apply",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<SeamanBookApplicationPage />
</RequirePermission>
),
@@ -259,7 +291,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registration",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationPage />
</RequirePermission>
),
@@ -277,7 +311,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-ownership-transfer",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselTransferPage />
</RequirePermission>
),
@@ -378,7 +414,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registrations",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationPage />
</RequirePermission>
),
@@ -387,7 +425,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registrations/:id",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationStatusPage />
</RequirePermission>
),

View File

@@ -724,6 +724,28 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
scheduleIssuance: builder.mutation<
LicenseApplication,
{ id: string; scheduledDate: string }
>({
query: ({ id, scheduledDate }) => ({
url: `/license-application-review/${id}/schedule-issuance`,
method: 'POST',
body: { scheduledDate },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
issueCertificate: builder.mutation<LicenseApplication, string>({
query: (id) => ({
url: `/license-application-review/${id}/issue-certificate`,
method: 'POST',
}),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
@@ -844,6 +866,8 @@ export const {
useScheduleExamMutation,
useRequestExamPaymentMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,

View File

@@ -1,6 +1,7 @@
import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
FamilyKind,
FormFieldConfig,
FormSectionConfig,
LicenseApplication,
@@ -69,6 +70,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing Certificate',
SCHEDULED: 'Pickup Scheduled',
CERTIFICATE_ISSUED: 'Certificate Issued',
COMPLETED: 'Completed',
ELIGIBILITY_APPROVED: 'Eligible to Sit',
@@ -94,6 +96,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
PAYMENT_PENDING: 'yellow',
PAID: 'lime',
PAYMENT_CONFIRMED: 'teal',
SCHEDULED: 'cyan',
CERTIFICATE_ISSUED: 'green',
COMPLETED: 'green',
ELIGIBILITY_APPROVED: 'teal',
@@ -125,6 +128,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
PAYMENT_PENDING: 80,
PAID: 88,
PAYMENT_CONFIRMED: 94,
SCHEDULED: 97,
CERTIFICATE_ISSUED: 100,
COMPLETED: 100,
REJECTED: 100,
@@ -169,9 +173,87 @@ export const APPLICANT_NAME_TYPE_KEYS = [
'ENDORSEMENT_GOC',
];
/** Company name, or applicant name for licence types that have no company. */
const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
SEAFARER_REGISTRATION: 'DOCUMENT',
SEAMAN_BOOK: 'DOCUMENT',
VESSEL_REGISTRATION: 'DOCUMENT',
BTC_BASIC_TRAINING: 'CERTIFICATE',
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
ENDORSEMENT_COC: 'CERTIFICATE',
ENDORSEMENT_GOC: 'CERTIFICATE',
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
SHIPPING_AGENT: 'LOGISTICS_LICENSE',
COMBINED_SA_FF: 'LOGISTICS_LICENSE',
MULTIMODAL_TRANSPORT_OPERATOR: 'LOGISTICS_LICENSE',
JOINT_INVESTOR: 'LOGISTICS_LICENSE',
};
/**
* `FamilyKind` for a type key, for data that predates `familyKind` being a
* real column on the server (cached responses from before the rollout, or
* anything that only ever had a bare key to go on). Every fresh response now
* carries `familyKind` directly from the server's own `family_kind` column —
* prefer that over calling this. Kept only as a fallback, and only for keys
* this map happens to know about; falls back further to `LOGISTICS_LICENSE`
* for anything else, reproducing today's "Licence ___" wording — the safe
* default for an unrecognised key.
*/
export function resolveFamilyKind(licenseTypeKey: string | undefined | null): FamilyKind {
return (licenseTypeKey && FAMILY_KIND_BY_KEY[licenseTypeKey]) || 'LOGISTICS_LICENSE';
}
export interface FamilyLabels {
/** e.g. "Certificate", "Document", "Licence". */
typeLabel: string;
/** e.g. "Certificate Number", "Document Number", "Licence Number". */
numberLabel: string;
/** e.g. "Certificate Holder", "Document Holder", "Licence Holder". */
holderLabel: string;
/** e.g. "Certificate Review", "Document Review", "Licence Review". */
reviewLabel: string;
}
const FAMILY_LABELS: Record<FamilyKind, FamilyLabels> = {
CERTIFICATE: {
typeLabel: 'Certificate',
numberLabel: 'Certificate Number',
holderLabel: 'Certificate Holder',
reviewLabel: 'Certificate Review',
},
DOCUMENT: {
typeLabel: 'Document',
numberLabel: 'Document Number',
holderLabel: 'Document Holder',
reviewLabel: 'Document Review',
},
LOGISTICS_LICENSE: {
typeLabel: 'Licence',
numberLabel: 'Licence Number',
holderLabel: 'Licence Holder',
reviewLabel: 'Licence Review',
},
};
export function familyLabels(familyKind: FamilyKind): FamilyLabels {
return FAMILY_LABELS[familyKind];
}
/**
* Company name, or applicant name for applications with no company.
*
* Branches on `familyKind` — the real data-model column — rather than a
* hand-maintained key list. A certificate or document is filed by a person,
* never a business, so it never has a `companyName` to show; a logistics
* licence always does. This is what used to be `APPLICANT_NAME_TYPE_KEYS`, a
* frontend array that had to be remembered and kept in sync by hand every
* time a new certificate/document type was added — it silently missed
* `SEAMAN_BOOK`/`BTC_BASIC_TRAINING`, which is why those rows rendered "—"
* instead of the applicant's name in the backoffice queue. `familyKind` is
* correct for every current and future type without a matching array update.
*/
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) {
if (app.familyKind === 'LOGISTICS_LICENSE') {
return app.companyName ?? undefined;
}
const applicantName = (app.formData?.account as Record<string, unknown> | undefined)

View File

@@ -2,7 +2,7 @@
// Reused rather than redeclared: the department vocabulary belongs to the
// seafarer domain, and two copies would drift.
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
import type { SeafarerDepartment } from "../seafarer/seafarer.types";
/**
* A backend `LocaleValidationDto`. Named for the two locales the UI offers, but
@@ -35,6 +35,10 @@ export type LicenseStatus =
| "PAYMENT_PENDING"
| "PAID"
| "PAYMENT_CONFIRMED"
// Payment confirmed and pickup date set — for a document printed once and
// handed over in person (seaman book, BTC). Most license types skip this
// and go straight from PAYMENT_CONFIRMED to CERTIFICATE_ISSUED.
| "SCHEDULED"
| "CERTIFICATE_ISSUED"
| "COMPLETED"
// Examined certificates (CoC, some CoP): approval establishes eligibility,
@@ -98,12 +102,25 @@ export interface FormSectionConfig {
/** Grouping the portal organises the licence catalogue by. */
export type LicenseCategory =
| 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY'
| 'INVESTMENT'
| 'MARITIME_PERSONNEL'
| 'VESSEL_SERVICES'
| 'WAIVER_SERVICES';
| "CARGO_FREIGHT"
| "SHIPPING_AGENCY"
| "INVESTMENT"
| "MARITIME_PERSONNEL"
| "VESSEL_SERVICES"
| "WAIVER_SERVICES";
/**
* Which business concept a licence type actually is — the real data-model
* classification (emaapi's `license_types.family_kind` column), not a label
* computed from `key`. A permission granted to a logistics operator, a
* seafarer's proof of competence, and a seafarer/vessel's identity or
* statutory record are three different things to the people using this
* system, even though they run through the identical application pipeline —
* see BR-MTO-020. Drives terminology, navigation, and which columns a grid
* shows (Company/TIN only make sense for LOGISTICS_LICENSE rows) — never a
* workflow or eligibility branch.
*/
export type FamilyKind = "LOGISTICS_LICENSE" | "CERTIFICATE" | "DOCUMENT";
export interface LicenseCategoryDefinition {
key: LicenseCategory;
@@ -131,6 +148,8 @@ export interface LicenseType {
name: Bilingual;
description?: Bilingual;
category: LicenseCategory;
/** The real data-model classification — see `FamilyKind`. */
familyKind: FamilyKind;
certificatePrefix: string;
feeNewApplication: string | number | null;
feeRenewal: string | number | null;
@@ -145,6 +164,8 @@ export interface LicenseType {
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
/** Payment confirmation waits for a scheduled pickup date before issuance. */
requiresIssuanceScheduling: boolean;
/**
* False for person-centric registrations (seafarer): they are open to any
* authenticated applicant, live outside the operator catalogue, and have
@@ -186,11 +207,7 @@ export interface LicenseType {
/** What kind of document a certificate type produces. */
export type CertificateCategory =
| "COC"
| "COP"
| "ENDORSEMENT"
| "GOC"
| "NATIONAL";
"COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL";
/**
* STCW responsibility level. Cadet is absent by design — under STCW a cadet is
@@ -260,7 +277,10 @@ export interface LicenseApplication {
applicationNumber: string;
licenseTypeId: string;
licenseType?: LicenseType;
/** Denormalized from `licenseType.familyKind` at submission time. */
familyKind: FamilyKind;
applicantUserId: string;
parentApplicationId?: string | null;
kind: ApplicationKind;
status: LicenseStatus;
assignedOfficerId: string | null;
@@ -279,6 +299,9 @@ export interface LicenseApplication {
feeAmount: string | null;
feeCurrency: string;
issuedLicenseId: string | null;
/** Set once an officer schedules pickup for a document requiring in-person handover. */
scheduledIssuanceDate: string | null;
scheduledBy: string | null;
createdAt: string;
}
@@ -369,6 +392,7 @@ export interface ApplicationApplicant {
export interface ApplicationDetail {
application: LicenseApplication;
relatedApplications?: LicenseApplication[];
/** Null when the applicant has no profile row (never expected in practice). */
applicant: ApplicationApplicant | null;
staff: ApplicationStaff[];
@@ -488,11 +512,7 @@ export interface TemplatePageOptions {
/** Corner the institute logo is anchored to. */
export type TemplateLogoCorner =
| 'TOP_LEFT'
| 'TOP_CENTER'
| 'TOP_RIGHT'
| 'BOTTOM_LEFT'
| 'BOTTOM_RIGHT';
"TOP_LEFT" | "TOP_CENTER" | "TOP_RIGHT" | "BOTTOM_LEFT" | "BOTTOM_RIGHT";
/** Where the institute logo sits on the certificate. */
export interface TemplateLogoPlacement {
@@ -519,8 +539,8 @@ export interface TemplateFieldPlacement {
yPct: number;
widthPct: number;
fontSize?: number;
fontWeight?: 'normal' | 'bold';
align?: 'left' | 'center' | 'right';
fontWeight?: "normal" | "bold";
align?: "left" | "center" | "right";
color?: string;
}
@@ -616,6 +636,8 @@ export interface IssuedLicense {
certificateNumber: string;
licenseTypeId: string;
licenseType?: LicenseType;
/** Denormalized from `licenseType.familyKind` at issuance time. */
familyKind: FamilyKind;
applicationId: string;
companyName: string | null;
tinNumber: string | null;

View File

@@ -31,6 +31,8 @@ export const LICENSE_PERMISSIONS = {
VIEW_INSPECTIONS: "can:View:inspections",
CONFIRM_PAYMENT: "can:confirm:license-payment",
VIEW_PAYMENTS: "can:View:license-payments",
SCHEDULE_ISSUANCE: "can:schedule:license-issuance",
ISSUE_CERTIFICATE: "can:issue:license-certificate",
CREATE_LICENSE_TYPE: "can:create:license-type",
VIEW_LICENSE_TYPES: "can:View:license-types",
UPDATE_LICENSE_TYPE: "can:update:license-type",