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 { 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' },
|
||||
];
|
||||
|
||||
@@ -280,6 +295,8 @@ const WORKFLOW_EVENT_IDS = new Set<ActionId>([
|
||||
'request-adjustment',
|
||||
'reject',
|
||||
'confirm-payment',
|
||||
'schedule-issuance',
|
||||
'issue-certificate',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,9 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
familyLabels,
|
||||
localized,
|
||||
resolveFamilyKind,
|
||||
useClaimApplicationMutation,
|
||||
useGetAllApplicationsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
@@ -326,6 +328,19 @@ export function LicenseQueuePage() {
|
||||
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" ? (
|
||||
@@ -390,7 +405,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 })}
|
||||
@@ -474,7 +489,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,
|
||||
@@ -577,7 +592,7 @@ export function LicenseQueuePage() {
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
tableName={t("queue.title", "Licence applications")}
|
||||
tableName={queueTitle}
|
||||
itemCount={total}
|
||||
pageIndex={page - 1}
|
||||
onPageChange={(pageIndex) => {
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleExamMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
@@ -186,6 +188,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();
|
||||
@@ -206,6 +210,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("");
|
||||
@@ -441,6 +447,9 @@ export function LicenseReviewPage() {
|
||||
case "schedule-inspection":
|
||||
setInspectionOpen(true);
|
||||
return;
|
||||
case "schedule-issuance":
|
||||
setIssuanceOpen(true);
|
||||
return;
|
||||
case "record-inspection":
|
||||
setResultOpen(true);
|
||||
return;
|
||||
@@ -584,6 +593,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(),
|
||||
@@ -1145,6 +1160,47 @@ 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)}
|
||||
|
||||
@@ -43,9 +43,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.
|
||||
*
|
||||
@@ -75,7 +76,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: "በቅርቡ",
|
||||
@@ -782,11 +783,12 @@ export const am: Translations = {
|
||||
|
||||
queue: {
|
||||
title: "የፈቃድ ማመልከቻዎች",
|
||||
titleByFamily: "{{family}} ማመልከቻዎች",
|
||||
search: "ፍለጋ",
|
||||
searchPlaceholder: "ኩባንያ፣ ቲን ወይም ቁጥር",
|
||||
status: "ሁኔታ",
|
||||
anyStatus: "ማንኛውም",
|
||||
type: "የፈቃድ ዓይነት",
|
||||
type: "ዓይነት",
|
||||
anyType: "ማንኛውም",
|
||||
typeCol: "ዓይነት",
|
||||
statusCol: "ሁኔታ",
|
||||
|
||||
@@ -54,6 +54,7 @@ export const en = {
|
||||
groupSeafarer: 'Seafarer Services',
|
||||
groupVessels: 'Vessels',
|
||||
groupExaminations: 'Examinations',
|
||||
groupShared: 'Shared Services',
|
||||
groupAdministration: 'Administration',
|
||||
groupAccount: 'Account',
|
||||
soon: 'Soon',
|
||||
@@ -784,11 +785,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',
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -141,6 +135,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: [
|
||||
|
||||
@@ -35,6 +35,8 @@ interface ApplicationSummary {
|
||||
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. */
|
||||
@@ -75,6 +77,9 @@ const STAGES: { label: string; statuses: string[] }[] = [
|
||||
{ label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] },
|
||||
{ label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] },
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
@@ -107,6 +112,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
PAYMENT_PENDING: 'orange',
|
||||
PAID: 'blue',
|
||||
PAYMENT_CONFIRMED: 'blue',
|
||||
SCHEDULED: 'grape',
|
||||
CERTIFICATE_ISSUED: 'teal',
|
||||
COMPLETED: 'teal',
|
||||
};
|
||||
@@ -239,19 +245,33 @@ export function SeamanBookPage() {
|
||||
{/* Active application status — one card per service in flight. */}
|
||||
{application && (
|
||||
<ApplicationCard title="Seaman Book" application={application}>
|
||||
{data?.book && (
|
||||
{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.
|
||||
</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 && (
|
||||
<ApplicationCard
|
||||
title="Basic Training Certificate"
|
||||
application={btcApplication}
|
||||
/>
|
||||
<ApplicationCard title="Basic Training Certificate" application={btcApplication}>
|
||||
{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 */}
|
||||
|
||||
Reference in New Issue
Block a user