feat: add workflow profiles to restrict status filtering and sync queue counts by license type

This commit is contained in:
estifanos
2026-08-18 10:07:22 +00:00
parent 2d91092a6b
commit de0dcbfbe8
3 changed files with 85 additions and 6 deletions

View File

@@ -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<QueueFilter["sortBy"]>) => {
const dir =
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
@@ -448,7 +516,7 @@ export function LicenseQueuePage() {
<MultiSelect
label={t("queue.status", "Status")}
placeholder={t("queue.anyStatus", "Any")}
data={ALL_STATUSES.map((s) => ({
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}
/>

View File

@@ -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<QueueCounts, void>({
query: () => ({ url: '/license-application-review/counts' }),
getQueueCounts: builder.query<QueueCounts, string | void>({
query: (licenseTypeKey) => ({
url: '/license-application-review/counts',
params: licenseTypeKey ? { licenseTypeKey } : undefined,
}),
providesTags: () => [listTag('ApplicationQueue')],
}),

View File

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