This commit is contained in:
Nati
2026-08-19 08:35:28 +00:00
parent 130082dcc4
commit f97eb68e25
12 changed files with 276 additions and 35 deletions

View File

@@ -1,5 +1,4 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api'; 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 * Where an action is rendered. One tier per action, decided here rather than
@@ -31,10 +30,11 @@ export type ActionId =
| 'reject' | 'reject'
| 'schedule-exam' | 'schedule-exam'
| 'confirm-payment' | 'confirm-payment'
| 'schedule-issuance'
| 'issue-certificate'
| 'print' | 'print'
| 'copy-link' | 'copy-link'
| 'download-documents' | 'download-documents'
| 'generate-certificate'
| 'audit-trail'; | 'audit-trail';
export interface ActionDefinition { export interface ActionDefinition {
@@ -211,6 +211,28 @@ export const ACTIONS: ActionDefinition[] = [
emphasis: 'filled', emphasis: 'filled',
color: 'teal', 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 // ------------------------------------------------------------ secondary
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' }, { id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
@@ -220,13 +242,6 @@ export const ACTIONS: ActionDefinition[] = [
tier: 'secondary', tier: 'secondary',
labelKey: 'review.actions.downloadDocuments', 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' }, { id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
]; ];
@@ -280,6 +295,8 @@ const WORKFLOW_EVENT_IDS = new Set<ActionId>([
'request-adjustment', 'request-adjustment',
'reject', 'reject',
'confirm-payment', 'confirm-payment',
'schedule-issuance',
'issue-certificate',
]); ]);
/** /**

View File

@@ -33,7 +33,9 @@ import { useTranslation } from "react-i18next";
import { import {
STATUS_LABELS, STATUS_LABELS,
extractErrorMessage, extractErrorMessage,
familyLabels,
localized, localized,
resolveFamilyKind,
useClaimApplicationMutation, useClaimApplicationMutation,
useGetAllApplicationsQuery, useGetAllApplicationsQuery,
useGetAssignedToMeQuery, useGetAssignedToMeQuery,
@@ -326,6 +328,19 @@ export function LicenseQueuePage() {
onHelp: () => setHelpOpen(true), 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 allSelected = items.length > 0 && selected.length === items.length;
const sortIcon = const sortIcon =
urlFilter.sortDir === "DESC" ? ( urlFilter.sortDir === "DESC" ? (
@@ -390,7 +405,7 @@ export function LicenseQueuePage() {
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}> <Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<div> <div>
<Title order={3}>{t("queue.title", "Licence applications")}</Title> <Title order={3}>{queueTitle}</Title>
{typeCode && ( {typeCode && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })} {t(`nav.type${typeCode}`, { defaultValue: typeCode })}
@@ -474,7 +489,7 @@ export function LicenseQueuePage() {
/> />
{!typeCode && ( {!typeCode && (
<Select <Select
label={t("queue.type", "Licence type")} label={t("queue.type", "Type")}
placeholder={t("queue.anyType", "Any")} placeholder={t("queue.anyType", "Any")}
data={(licenseTypes?.items ?? []).map((type) => ({ data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id, value: type.id,
@@ -577,7 +592,7 @@ export function LicenseQueuePage() {
<AdvancedTable <AdvancedTable
columns={columns} columns={columns}
data={items} data={items}
tableName={t("queue.title", "Licence applications")} tableName={queueTitle}
itemCount={total} itemCount={total}
pageIndex={page - 1} pageIndex={page - 1}
onPageChange={(pageIndex) => { onPageChange={(pageIndex) => {

View File

@@ -41,6 +41,8 @@ import {
useAssignApplicationMutation, useAssignApplicationMutation,
useCompleteReviewMutation, useCompleteReviewMutation,
useConfirmPaymentMutation, useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleExamMutation, useScheduleExamMutation,
useEscalateApplicationMutation, useEscalateApplicationMutation,
useFinalApproveMutation, useFinalApproveMutation,
@@ -186,6 +188,8 @@ export function LicenseReviewPage() {
const [scheduleInspection] = useScheduleInspectionMutation(); const [scheduleInspection] = useScheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation(); const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation(); const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
const [issueCertificate] = useIssueCertificateMutation();
const [scheduleExam, { isLoading: schedulingExam }] = const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation(); useScheduleExamMutation();
const [holdApplication] = useHoldApplicationMutation(); const [holdApplication] = useHoldApplicationMutation();
@@ -206,6 +210,8 @@ export function LicenseReviewPage() {
const [railOpen, setRailOpen] = useState(true); const [railOpen, setRailOpen] = useState(true);
const [inspectionOpen, setInspectionOpen] = useState(false); const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState(""); const [inspectionDate, setInspectionDate] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false); const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false); const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [findings, setFindings] = useState(""); const [findings, setFindings] = useState("");
@@ -441,6 +447,9 @@ export function LicenseReviewPage() {
case "schedule-inspection": case "schedule-inspection":
setInspectionOpen(true); setInspectionOpen(true);
return; return;
case "schedule-issuance":
setIssuanceOpen(true);
return;
case "record-inspection": case "record-inspection":
setResultOpen(true); setResultOpen(true);
return; return;
@@ -584,6 +593,12 @@ export function LicenseReviewPage() {
t("review.done.confirmPayment", "Payment confirmed"), t("review.done.confirmPayment", "Payment confirmed"),
); );
break; break;
case "issue-certificate":
await run(
() => issueCertificate(id).unwrap(),
t("review.done.issueCertificate", "Certificate issued"),
);
break;
case "hold": case "hold":
await run( await run(
() => holdApplication({ id, reason: submission.reason }).unwrap(), () => holdApplication({ id, reason: submission.reason }).unwrap(),
@@ -1145,6 +1160,47 @@ export function LicenseReviewPage() {
</Stack> </Stack>
</Modal> </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 <Modal
opened={resultOpen} opened={resultOpen}
onClose={() => setResultOpen(false)} onClose={() => setResultOpen(false)}

View File

@@ -43,9 +43,10 @@ import { paymentConfigColumns } from './columns';
import { paymentConfigActionsColumn } from './actions'; 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 * 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. * the applicant is charged, not a parallel copy of it.
* *
@@ -75,7 +76,7 @@ export function PaymentConfigPage() {
<Alert <Alert
color="red" color="red"
icon={<IconAlertTriangle size={18} />} 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> <Text size="sm">{extractErrorMessage(error)}</Text>
</Alert> </Alert>

View File

@@ -55,6 +55,7 @@ export const am: Translations = {
groupSeafarer: "የመርከበኞች አገልግሎት", groupSeafarer: "የመርከበኞች አገልግሎት",
groupVessels: "መርከቦች", groupVessels: "መርከቦች",
groupExaminations: "ፈተናዎች", groupExaminations: "ፈተናዎች",
groupShared: "የጋራ አገልግሎቶች",
groupAdministration: "አስተዳደር", groupAdministration: "አስተዳደር",
groupAccount: "መለያ", groupAccount: "መለያ",
soon: "በቅርቡ", soon: "በቅርቡ",
@@ -782,11 +783,12 @@ export const am: Translations = {
queue: { queue: {
title: "የፈቃድ ማመልከቻዎች", title: "የፈቃድ ማመልከቻዎች",
titleByFamily: "{{family}} ማመልከቻዎች",
search: "ፍለጋ", search: "ፍለጋ",
searchPlaceholder: "ኩባንያ፣ ቲን ወይም ቁጥር", searchPlaceholder: "ኩባንያ፣ ቲን ወይም ቁጥር",
status: "ሁኔታ", status: "ሁኔታ",
anyStatus: "ማንኛውም", anyStatus: "ማንኛውም",
type: "የፈቃድ ዓይነት", type: "ዓይነት",
anyType: "ማንኛውም", anyType: "ማንኛውም",
typeCol: "ዓይነት", typeCol: "ዓይነት",
statusCol: "ሁኔታ", statusCol: "ሁኔታ",

View File

@@ -54,6 +54,7 @@ export const en = {
groupSeafarer: 'Seafarer Services', groupSeafarer: 'Seafarer Services',
groupVessels: 'Vessels', groupVessels: 'Vessels',
groupExaminations: 'Examinations', groupExaminations: 'Examinations',
groupShared: 'Shared Services',
groupAdministration: 'Administration', groupAdministration: 'Administration',
groupAccount: 'Account', groupAccount: 'Account',
soon: 'Soon', soon: 'Soon',
@@ -784,11 +785,15 @@ export const en = {
queue: { queue: {
title: 'Licence applications', 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', search: 'Search',
searchPlaceholder: 'Company, TIN or number', searchPlaceholder: 'Company, TIN or number',
status: 'Status', status: 'Status',
anyStatus: 'Any', anyStatus: 'Any',
type: 'Licence type', type: 'Type',
anyType: 'Any', anyType: 'Any',
typeCol: 'Type', typeCol: 'Type',
statusCol: 'Status', statusCol: 'Status',

View File

@@ -37,9 +37,15 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
/** /**
* The backoffice information architecture. * 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 * screens with no backend behind them, so a reviewer can tell at a glance what
* actually works. * 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[] = [ export const NAV_SECTIONS: NavSection[] = [
{ {
@@ -74,12 +80,6 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconListCheck, icon: IconListCheck,
permissions: [P.VIEW_LICENSES], 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/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', 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, icon: IconGauge,
permissions: APPLICATION_QUEUE, permissions: APPLICATION_QUEUE,
}, },
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
], ],
}, },
{ {
@@ -141,6 +135,23 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] }, { 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', label: 'nav.groupAdministration',
items: [ items: [

View File

@@ -35,6 +35,8 @@ interface ApplicationSummary {
applicationId: string; applicationId: string;
status: string; status: string;
submittedAt: 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. */ /** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
@@ -75,6 +77,9 @@ const STAGES: { label: string; statuses: string[] }[] = [
{ label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] }, { label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] }, { label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] },
{ label: 'Approved', statuses: ['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED'] }, { 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'] }, { label: 'Issued', statuses: ['CERTIFICATE_ISSUED', 'COMPLETED'] },
]; ];
@@ -107,6 +112,7 @@ const STATUS_COLOR: Record<string, string> = {
PAYMENT_PENDING: 'orange', PAYMENT_PENDING: 'orange',
PAID: 'blue', PAID: 'blue',
PAYMENT_CONFIRMED: 'blue', PAYMENT_CONFIRMED: 'blue',
SCHEDULED: 'grape',
CERTIFICATE_ISSUED: 'teal', CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal', COMPLETED: 'teal',
}; };
@@ -239,19 +245,33 @@ export function SeamanBookPage() {
{/* Active application status — one card per service in flight. */} {/* Active application status — one card per service in flight. */}
{application && ( {application && (
<ApplicationCard title="Seaman Book" application={application}> <ApplicationCard title="Seaman Book" application={application}>
{data?.book && ( {data?.book ? (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md"> <Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book <strong>{data.book.id}</strong> has been issued. 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> </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> </ApplicationCard>
)} )}
{btcApplication && ( {btcApplication && (
<ApplicationCard <ApplicationCard title="Basic Training Certificate" application={btcApplication}>
title="Basic Training Certificate" {btcApplication.status === 'SCHEDULED' && btcApplication.scheduledIssuanceDate && (
application={btcApplication} <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 */} {/* No active application — eligibility + apply */}

View File

@@ -721,6 +721,28 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], 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 // --------------------------------------------------------- inspection
scheduleInspection: builder.mutation< scheduleInspection: builder.mutation<
Inspection, Inspection,
@@ -841,6 +863,8 @@ export const {
useScheduleExamMutation, useScheduleExamMutation,
useRequestExamPaymentMutation, useRequestExamPaymentMutation,
useConfirmPaymentMutation, useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleInspectionMutation, useScheduleInspectionMutation,
useGetInspectionsQuery, useGetInspectionsQuery,
useRecordInspectionResultMutation, useRecordInspectionResultMutation,

View File

@@ -69,6 +69,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
PAYMENT_PENDING: 'Payment Pending', PAYMENT_PENDING: 'Payment Pending',
PAID: 'Paid', PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing Certificate', PAYMENT_CONFIRMED: 'Preparing Certificate',
SCHEDULED: 'Pickup Scheduled',
CERTIFICATE_ISSUED: 'Certificate Issued', CERTIFICATE_ISSUED: 'Certificate Issued',
COMPLETED: 'Completed', COMPLETED: 'Completed',
ELIGIBILITY_APPROVED: 'Eligible to Sit', ELIGIBILITY_APPROVED: 'Eligible to Sit',
@@ -94,6 +95,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
PAYMENT_PENDING: 'yellow', PAYMENT_PENDING: 'yellow',
PAID: 'lime', PAID: 'lime',
PAYMENT_CONFIRMED: 'teal', PAYMENT_CONFIRMED: 'teal',
SCHEDULED: 'cyan',
CERTIFICATE_ISSUED: 'green', CERTIFICATE_ISSUED: 'green',
COMPLETED: 'green', COMPLETED: 'green',
ELIGIBILITY_APPROVED: 'teal', ELIGIBILITY_APPROVED: 'teal',
@@ -125,6 +127,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
PAYMENT_PENDING: 80, PAYMENT_PENDING: 80,
PAID: 88, PAID: 88,
PAYMENT_CONFIRMED: 94, PAYMENT_CONFIRMED: 94,
SCHEDULED: 97,
CERTIFICATE_ISSUED: 100, CERTIFICATE_ISSUED: 100,
COMPLETED: 100, COMPLETED: 100,
REJECTED: 100, REJECTED: 100,
@@ -169,6 +172,82 @@ export const APPLICANT_NAME_TYPE_KEYS = [
'ENDORSEMENT_GOC', 'ENDORSEMENT_GOC',
]; ];
/**
* Which business concept a licence type actually is, for terminology and
* navigation only — never a workflow or eligibility branch. Mirrors
* `resolveFamilyKind` in emaapi's `common/utils/family-terminology.ts`; the
* two must be kept in step by hand since the value isn't sent over the wire
* (nothing needs it server-side beyond notification copy, so a shared
* package felt like more plumbing than the duplication it would save).
*
* 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.
*/
export type FamilyKind = 'LOGISTICS_LICENSE' | 'CERTIFICATE' | 'DOCUMENT';
const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
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 that may not be in the map yet. Falls back to
* `LOGISTICS_LICENSE`, reproducing today's "Licence ___" wording — the safe
* default for an unrecognised key, matching how the map above treats config
* it doesn't know about.
*/
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 licence types that have no company. */ /** Company name, or applicant name for licence types that have no company. */
export function applicantOrCompanyName(app: LicenseApplication): string | undefined { export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) { if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) {

View File

@@ -35,6 +35,10 @@ export type LicenseStatus =
| "PAYMENT_PENDING" | "PAYMENT_PENDING"
| "PAID" | "PAID"
| "PAYMENT_CONFIRMED" | "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" | "CERTIFICATE_ISSUED"
| "COMPLETED" | "COMPLETED"
// Examined certificates (CoC, some CoP): approval establishes eligibility, // Examined certificates (CoC, some CoP): approval establishes eligibility,
@@ -145,6 +149,8 @@ export interface LicenseType {
inspectionRequired: boolean; inspectionRequired: boolean;
issuesCertificate: boolean; issuesCertificate: boolean;
renewalEnabled: 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 * False for person-centric registrations (seafarer): they are open to any
* authenticated applicant, live outside the operator catalogue, and have * authenticated applicant, live outside the operator catalogue, and have
@@ -274,6 +280,9 @@ export interface LicenseApplication {
feeAmount: string | null; feeAmount: string | null;
feeCurrency: string; feeCurrency: string;
issuedLicenseId: string | null; issuedLicenseId: string | null;
/** Set once an officer schedules pickup for a document requiring in-person handover. */
scheduledIssuanceDate: string | null;
scheduledBy: string | null;
createdAt: string; createdAt: string;
} }

View File

@@ -31,6 +31,8 @@ export const LICENSE_PERMISSIONS = {
VIEW_INSPECTIONS: "can:View:inspections", VIEW_INSPECTIONS: "can:View:inspections",
CONFIRM_PAYMENT: "can:confirm:license-payment", CONFIRM_PAYMENT: "can:confirm:license-payment",
VIEW_PAYMENTS: "can:View:license-payments", 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", CREATE_LICENSE_TYPE: "can:create:license-type",
VIEW_LICENSE_TYPES: "can:View:license-types", VIEW_LICENSE_TYPES: "can:View:license-types",
UPDATE_LICENSE_TYPE: "can:update:license-type", UPDATE_LICENSE_TYPE: "can:update:license-type",