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 3013686e6..6ff6d7a2e 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 @@ -43,6 +43,7 @@ import { useLazyExportApplicationsQuery, type LicenseApplication, type LicenseStatus, + type LicenseType, type QueueFilter, } from "@ema-platform/api"; import { @@ -72,6 +73,17 @@ import { licenseQueueActionsColumn } from "./actions"; const PAGE_SIZE = 10; const SEARCH_DEBOUNCE_MS = 300; +/** + * Statuses only the STANDARD course can reach. A registration goes straight + * review → approval, so offering these in its facet would be offering filters + * that can only ever match nothing. + */ +const STANDARD_ONLY_STATUSES: LicenseStatus[] = [ + "UNDER_EVALUATION", + "INSPECTION_PENDING", + "INSPECTION_COMPLETED", +]; + const ALL_STATUSES: LicenseStatus[] = [ "SUBMITTED", "UNDER_REVIEW", @@ -89,6 +101,24 @@ const ALL_STATUSES: LicenseStatus[] = [ "REJECTED", ]; +/** Statuses an application of this type can actually occupy. */ +function statusesFor(type: LicenseType | undefined): LicenseStatus[] { + if (!type) return ALL_STATUSES; + return ALL_STATUSES.filter((status) => { + if ( + type.workflowProfile === "REGISTRATION" && + STANDARD_ONLY_STATUSES.includes(status) + ) { + return false; + } + if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") { + return type.inspectionRequired; + } + if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate; + return true; + }); +} + /** * The officer work pool. * @@ -123,14 +153,37 @@ export function LicenseQueuePage() { const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0]; const { data: licenseTypes } = useGetLicenseTypesQuery(); - const { data: counts } = useGetQueueCountsQuery(); - // A `/licence-review/type/:typeCode` deep link pins the type facet. const pinnedTypeId = useMemo(() => { if (!typeCode) return undefined; return licenseTypes?.items?.find((type) => type.key === typeCode)?.id; }, [typeCode, licenseTypes]); + // Counts endpoint keys off `key`, the facet off `id` — resolve whichever the + // route or the dropdown set, so the tab badges always count the same rows + // the grid is showing rather than system-wide totals. + const countsKey = useMemo( + () => + typeCode ?? + licenseTypes?.items?.find((type) => type.id === urlFilter.licenseTypeId) + ?.key, + [typeCode, urlFilter.licenseTypeId, licenseTypes], + ); + const { data: counts } = useGetQueueCountsQuery(countsKey); + + const selectedType = useMemo(() => { + const typeId = pinnedTypeId ?? urlFilter.licenseTypeId; + if (!typeId) return undefined; + return licenseTypes?.items?.find((type) => type.id === typeId); + }, [pinnedTypeId, urlFilter.licenseTypeId, licenseTypes]); + + // The facet offers what the chosen type can actually reach. With no type + // chosen the queue spans every course, so the full list is correct. + const statusOptions = useMemo( + () => statusesFor(selectedType), + [selectedType], + ); + const filter: QueueFilter = useMemo( () => ({ ...activeView.filter, @@ -234,6 +287,21 @@ export function LicenseQueuePage() { updateUrl(next, view, 1); }; + /** + * Switching type drops any selected status the new type cannot reach — + * otherwise the facet keeps an invisible filter that matches nothing and the + * grid looks empty for no reason the officer can see. + */ + const changeType = (typeId: string | undefined) => { + const allowed = statusesFor( + licenseTypes?.items?.find((type) => type.id === typeId), + ); + setFacet({ + licenseTypeId: typeId, + status: urlFilter.status?.filter((s) => allowed.includes(s)), + }); + }; + const toggleSort = (field: NonNullable) => { const dir = urlFilter.sortBy === field && urlFilter.sortDir !== "DESC" @@ -448,7 +516,7 @@ export function LicenseQueuePage() { ({ + data={statusOptions.map((s) => ({ value: s, label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]), }))} @@ -466,7 +534,7 @@ export function LicenseQueuePage() { label: localized(type.name, i18n.language) || type.key, }))} value={urlFilter.licenseTypeId ?? null} - onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })} + onChange={(v) => changeType(v ?? undefined)} clearable w={220} /> diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 3a0b2148d..bbe665292 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -398,8 +398,11 @@ export const licensingApi = baseApi * claim or a decision refreshes the badges along with the list and the * numbers can never drift from what the grid is showing. */ - getQueueCounts: builder.query({ - query: () => ({ url: '/license-application-review/counts' }), + getQueueCounts: builder.query({ + query: (licenseTypeKey) => ({ + url: '/license-application-review/counts', + params: licenseTypeKey ? { licenseTypeKey } : undefined, + }), providesTags: () => [listTag('ApplicationQueue')], }), diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 306ba17aa..e15d0a11f 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -141,6 +141,11 @@ export interface LicenseType { */ requiresOperatorMode: boolean; formSchema: { sections: FormSectionConfig[] }; + /** + * Which course an application of this type runs. REGISTRATION skips the + * evaluation and inspection stages, so those statuses are unreachable for it. + */ + workflowProfile?: WorkflowProfile; isActive: boolean; /** Display order set by EMA; lower comes first. */ sortOrder: number; @@ -382,6 +387,9 @@ export type QueueSortField = | "dueAt" | "claimedAt"; +/** Review → evaluation → (inspection) → approval, or the short registration course. */ +export type WorkflowProfile = "STANDARD" | "REGISTRATION"; + /** Row counts behind the queue's saved-view tabs. */ export interface QueueCounts { unassigned: number;