diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 9556bb460..c5532deec 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -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([ 'request-adjustment', 'reject', 'confirm-payment', + 'schedule-issuance', + 'issue-certificate', ]); /** 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" ? ( 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>; @@ -27,11 +51,12 @@ export function licenseQueueColumns( label: string, field: NonNullable, ) => ReactNode; + /** Set for a type-pinned queue; undefined for the mixed All/Mine grids. */ + isLogistics?: boolean; }, ): AdvancedColumn[] { - const { typeCode, 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 }) => ( - - {row.original.tinNumber ?? "—"} - - ), - }, { 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" ? ( + + {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 750254224..6ef442da9 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 @@ -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( () => (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[] = 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() {
- {t("queue.title", "Licence applications")} + {queueTitle} {typeCode && ( {t(`nav.type${typeCode}`, { defaultValue: typeCode })} @@ -499,7 +530,7 @@ export function LicenseQueuePage() { mb="sm" > - {SAVED_VIEWS.map((savedView) => ( + {visibleViews.map((savedView) => ( {!typeCode && (