Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
nati14575
2026-08-19 16:49:12 +03:00
19 changed files with 818 additions and 189 deletions

View File

@@ -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',
]);
/**

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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");
});
});

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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: "ዕድሜ / የጊዜ ገደብ",

View File

@@ -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',

View File

@@ -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: [