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,17 +753,19 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
{isLogistics !== false && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user