From b458bae35cc75322133d31f3b651c03f04f9a338 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 05:52:56 +0000 Subject: [PATCH 01/27] refactor: introduce hasUnclaimedPool helper to allow claim workflows for specific document-family queues --- .../pages/LicenseQueuePage/index.tsx | 34 ++++++++++--------- .../license-review/queue-views.test.ts | 26 ++++++++++++++ .../features/license-review/queue-views.ts | 32 ++++++++++++++--- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index b8791eb4e..ed3a5576c 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -61,6 +61,7 @@ import { SAVED_VIEWS, filterFromSearchParams, readLastView, + hasUnclaimedPool, savedViewsForFamily, searchParamsFromFilter, writeLastView, @@ -159,12 +160,16 @@ export function LicenseQueuePage() { const isLogistics = typeCode ? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE" : undefined; - const visibleViews = savedViewsForFamily(isLogistics !== false); + // Claiming is a workflow property, not a label one — Vessel Registration is + // a DOCUMENT family but is still triaged off a shared unclaimed pool, so it + // keeps the Unassigned tab and the claim actions. + const claimable = hasUnclaimedPool(typeCode); + const visibleViews = savedViewsForFamily(claimable); const [view, setView] = useState( () => (searchParams.get("view") as SavedViewId) || - (isLogistics === false ? "all" : readLastView()), + (claimable ? readLastView() : "all"), ); const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1); const [pageSize, setPageSize] = useState(PAGE_SIZE); @@ -174,21 +179,17 @@ export function LicenseQueuePage() { const [helpOpen, setHelpOpen] = useState(false); const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS); - // Non-logistics queues have no unassigned/unclaimed pool (see - // `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from + // Queues with no unclaimed pool (see `hasUnclaimedPool`) have no unassigned + // tab, 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 ( - isLogistics === false && - !searchParams.has("view") && - view === "unassigned" - ) { + if (!claimable && !searchParams.has("view") && view === "unassigned") { setView("all"); } - }, [isLogistics, searchParams, view]); + }, [claimable, searchParams, view]); const urlFilter = useMemo( () => filterFromSearchParams(searchParams), @@ -414,9 +415,9 @@ export function LicenseQueuePage() { onPrevious: () => setCursor((c) => Math.max(c - 1, 0)), onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`), onClaim: () => { - // Only unclaimed rows on a logistics queue can be claimed; pressing c + // Only unclaimed rows on a claimable 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) + if (claimable && cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id); }, onEscape: () => setSelected([]), @@ -481,9 +482,9 @@ export function LicenseQueuePage() { 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, + // Applications with no unclaimed pool (see `hasUnclaimedPool`) are + // never claimed — every row opens straight to Review. + claimable, }), ], [ @@ -496,6 +497,7 @@ export function LicenseQueuePage() { items, claiming, isLogistics, + claimable, ], ); @@ -767,7 +769,7 @@ export function LicenseQueuePage() { > {t("queue.export", "Export CSV")} - {isLogistics !== false && ( + {claimable && ( { it("treats person-centric seafarer applications as document queues", () => { @@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => { expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE"); }); }); + +describe("hasUnclaimedPool", () => { + it("keeps the unassigned pool for logistics licences", () => { + expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true); + }); + + it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => { + expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT"); + expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true); + expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain( + "unassigned", + ); + }); + + it("drops it for the person-centric services", () => { + expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false); + expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain( + "unassigned", + ); + }); + + it("keeps it for the mixed All/Mine grids, where no type is pinned", () => { + expect(hasUnclaimedPool(undefined)).toBe(true); + }); +}); diff --git a/apps/backoffice/src/app/features/license-review/queue-views.ts b/apps/backoffice/src/app/features/license-review/queue-views.ts index 6689b73a0..6b167779b 100644 --- a/apps/backoffice/src/app/features/license-review/queue-views.ts +++ b/apps/backoffice/src/app/features/license-review/queue-views.ts @@ -1,3 +1,4 @@ +import { resolveFamilyKind } from '@ema-platform/api'; import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api'; export type SavedViewId = @@ -78,12 +79,33 @@ 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. + * Type keys reviewed off a shared unclaimed pool despite not being logistics + * licences. Whether a queue is claimed is an officer-workflow property, not a + * label one: vessel registrations arrive unassigned and officers claim them, + * even though the family kind is DOCUMENT because the certificate they produce + * is a document rather than a licence. */ -export function savedViewsForFamily(isLogistics: boolean): SavedView[] { - return isLogistics +const CLAIMABLE_NON_LOGISTICS = new Set(['VESSEL_REGISTRATION']); + +/** + * Does this queue have an unclaimed pool to triage? + * + * True for the mixed All/Mine grids (no type pinned) — nothing is hidden when + * the queue spans every type. False for the person-centric services (Seafarer + * Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed + * off a shared queue, so the tab and the claim actions don't apply there. + */ +export function hasUnclaimedPool(typeCode: string | undefined): boolean { + if (!typeCode) return true; + return ( + resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' || + CLAIMABLE_NON_LOGISTICS.has(typeCode) + ); +} + +/** Drops the Unassigned tab on queues with no unclaimed pool. */ +export function savedViewsForFamily(claimable: boolean): SavedView[] { + return claimable ? SAVED_VIEWS : SAVED_VIEWS.filter((v) => v.id !== 'unassigned'); } From 841579468a4665c39ba55393ae45e64349f0f4f2 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 06:09:44 +0000 Subject: [PATCH 02/27] feat: add claim application functionality to the license review page --- .../license-review/pages/LicenseReviewPage/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index c26ec2cc8..166b512eb 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -533,11 +533,11 @@ export function LicenseReviewPage() { try { switch (action.id) { case "claim": - // Usually fired from the queue, but an officer can also open an - // unclaimed application directly and claim it from here. + // Usually fired from the queue, but an officer who opened an + // unclaimed application directly claims it from here. await run( () => claimApplication(id).unwrap(), - t("review.done.claim", "Application claimed"), + t("review.done.claim", "Claimed — the application is now yours"), ); break; case "complete-review": From ae0dfdece36b9e738098dc0e8a62eda1d14a7c79 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 06:57:22 +0000 Subject: [PATCH 03/27] refactor: update vessel registration route path in onboarding navigation --- .../app/features/onboarding/pages/OperationsOnboardingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx index 34d98dc83..8499b8e4f 100644 --- a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx +++ b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx @@ -19,7 +19,7 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo */ const NEXT_STEP: Record = { SEAFARER_REGISTRATION: "/seafarer-registration", - VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply", + VESSEL_REGISTRATION: "/vessel-registration", }; function nextStepFor(selectedKeys: string[]): string { From 0ac969cca2c727fe8657a37304fa58536472bacf Mon Sep 17 00:00:00 2001 From: Fistum Date: Sat, 22 Aug 2026 07:54:35 +0000 Subject: [PATCH 04/27] logestics chnage --- .../components/AssignDialog.tsx | 113 ++++++++++++++++++ .../components/DecisionConfirmModal.tsx | 14 ++- .../features/license-review/config/actions.ts | 54 ++++++++- .../pages/LicenseQueuePage/actions.tsx | 29 +++-- .../pages/LicenseQueuePage/index.tsx | 98 ++++++--------- .../pages/LicenseReviewPage/index.tsx | 51 ++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 9 ++ apps/backoffice/src/app/i18n/locales/en.ts | 9 ++ .../lib/features/licensing/licensing-api.ts | 64 ++++++++++ .../features/licensing/licensing.helpers.ts | 8 ++ .../lib/features/licensing/licensing.types.ts | 4 + 11 files changed, 380 insertions(+), 73 deletions(-) create mode 100644 apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx diff --git a/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx new file mode 100644 index 000000000..9afca696c --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/AssignDialog.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { Modal, Select, Stack, Text, Textarea } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useGetAssignableOfficersQuery } from '@ema-platform/api'; +import { ModalFooter } from '@ema-platform/ui'; +import { Button } from '@mantine/core'; + +export type AssignKind = 'review' | 'inspection'; + +interface AssignDialogProps { + opened: boolean; + onClose: () => void; + /** Which stage is being handed out — changes the wording, not the mechanics. */ + kind: AssignKind; + /** Reference of the application being dispatched, shown for confirmation. */ + applicationNumber?: string; + loading?: boolean; + onConfirm: (officerId: string, remark?: string) => void; +} + +/** + * The team leader handing work to an employee. + * + * One dialog for both stages because the decision is identical — pick a person, + * optionally say why — and two near-identical modals would drift apart. The + * `kind` only selects wording. + * + * Confirm stays disabled until someone is picked: an assignment with no + * assignee is the one mistake this dialog exists to prevent. + */ +export function AssignDialog({ + opened, + onClose, + kind, + applicationNumber, + loading, + onConfirm, +}: AssignDialogProps) { + const { t } = useTranslation(); + const { data: officers = [], isLoading } = useGetAssignableOfficersQuery(); + const [officerId, setOfficerId] = useState(null); + const [remark, setRemark] = useState(''); + + // Reopening for a different application must not offer the previous + // dialog's answers as if they had been chosen for this one. + useEffect(() => { + if (opened) { + setOfficerId(null); + setRemark(''); + } + }, [opened]); + + const title = + kind === 'review' + ? t('queue.assignReview', 'Assign document review') + : t('queue.assignInspection', 'Assign inspection'); + + return ( + + + {applicationNumber && ( + + {applicationNumber} + + )} + +