diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx index 0c4a7dff3..4362f2416 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx @@ -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 { 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" ? ( , ) => ReactNode; + /** Set for a type-pinned queue; undefined for the mixed All/Mine grids. */ + isLogistics?: boolean; }, ): AdvancedColumn[] { - const { items, selected, setSelected, allSelected, sortableHeader } = opts; - return [ + const { items, selected, setSelected, allSelected, sortableHeader, isLogistics } = opts; + const columns: AdvancedColumn[] = [ { header: ( ( {applicantOrCompanyName(row.original) ?? "—"} ), }, - { - header: t("queue.tin", "TIN"), - cell: ({ row }) => - // A certificate/document application is filed by a person, never a - // business — there is no TIN to show, not even a blank one. Hiding - // the cell (rather than "—") is what makes the row visibly not a - // logistics-licence row, which is the whole point of `familyKind` - // being a real column now instead of a frontend guess. - row.original.familyKind === "LOGISTICS_LICENSE" ? ( - - {row.original.tinNumber ?? "—"} - - ) : null, - }, { header: t("queue.typeCol", "Type"), cell: ({ row }) => ( @@ -161,4 +157,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" ? ( + + {row.original.tinNumber ?? "—"} + + ) : null, + }); + } + + return columns; } 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 11b03f602..ebc99cbaf 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 @@ -60,6 +60,7 @@ import { SAVED_VIEWS, filterFromSearchParams, readLastView, + savedViewsForFamily, searchParamsFromFilter, writeLastView, type SavedViewId, @@ -107,10 +108,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( () => (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); @@ -120,18 +130,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), @@ -319,9 +332,9 @@ 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([]), @@ -380,11 +393,15 @@ export function LicenseQueuePage() { 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, }), ], [ @@ -396,6 +413,7 @@ export function LicenseQueuePage() { allSelected, items, claiming, + isLogistics, ], ); @@ -444,7 +462,7 @@ export function LicenseQueuePage() { mb="sm" > - {SAVED_VIEWS.map((savedView) => ( + {visibleViews.map((savedView) => ( {t("queue.export", "Export CSV")} - - - + {isLogistics !== false && ( + + + + )} diff --git a/apps/backoffice/src/app/features/license-review/queue-views.test.ts b/apps/backoffice/src/app/features/license-review/queue-views.test.ts new file mode 100644 index 000000000..8e9556c18 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/queue-views.test.ts @@ -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'); + }); +}); 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 ca6d79482..6689b73a0 100644 --- a/apps/backoffice/src/app/features/license-review/queue-views.ts +++ b/apps/backoffice/src/app/features/license-review/queue-views.ts @@ -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 {