mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,6 +753,7 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
{isLogistics !== false && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
@@ -733,6 +765,7 @@ export function LicenseQueuePage() {
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "ዕድሜ / የጊዜ ገደብ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}>5–7 working days</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Processing time
|
||||
</Text>
|
||||
<Text fz="sm" fw={600}>
|
||||
5–7 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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user