mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Major
This commit is contained in:
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: "ሁኔታ",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user