mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 09:58:13 +00:00
feat: add workflow profiles to restrict status filtering and sync queue counts by license type
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
|||||||
useLazyExportApplicationsQuery,
|
useLazyExportApplicationsQuery,
|
||||||
type LicenseApplication,
|
type LicenseApplication,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
|
type LicenseType,
|
||||||
type QueueFilter,
|
type QueueFilter,
|
||||||
} from "@ema-platform/api";
|
} from "@ema-platform/api";
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +73,17 @@ import { licenseQueueActionsColumn } from "./actions";
|
|||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
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[] = [
|
const ALL_STATUSES: LicenseStatus[] = [
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
"UNDER_REVIEW",
|
"UNDER_REVIEW",
|
||||||
@@ -89,6 +101,24 @@ const ALL_STATUSES: LicenseStatus[] = [
|
|||||||
"REJECTED",
|
"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.
|
* The officer work pool.
|
||||||
*
|
*
|
||||||
@@ -123,14 +153,37 @@ export function LicenseQueuePage() {
|
|||||||
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
||||||
|
|
||||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||||
const { data: counts } = useGetQueueCountsQuery();
|
|
||||||
|
|
||||||
// A `/licence-review/type/:typeCode` deep link pins the type facet.
|
// A `/licence-review/type/:typeCode` deep link pins the type facet.
|
||||||
const pinnedTypeId = useMemo(() => {
|
const pinnedTypeId = useMemo(() => {
|
||||||
if (!typeCode) return undefined;
|
if (!typeCode) return undefined;
|
||||||
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
|
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
|
||||||
}, [typeCode, licenseTypes]);
|
}, [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(
|
const filter: QueueFilter = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...activeView.filter,
|
...activeView.filter,
|
||||||
@@ -234,6 +287,21 @@ export function LicenseQueuePage() {
|
|||||||
updateUrl(next, view, 1);
|
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 toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||||||
const dir =
|
const dir =
|
||||||
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
||||||
@@ -448,7 +516,7 @@ export function LicenseQueuePage() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={t("queue.status", "Status")}
|
label={t("queue.status", "Status")}
|
||||||
placeholder={t("queue.anyStatus", "Any")}
|
placeholder={t("queue.anyStatus", "Any")}
|
||||||
data={ALL_STATUSES.map((s) => ({
|
data={statusOptions.map((s) => ({
|
||||||
value: s,
|
value: s,
|
||||||
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
||||||
}))}
|
}))}
|
||||||
@@ -466,7 +534,7 @@ export function LicenseQueuePage() {
|
|||||||
label: localized(type.name, i18n.language) || type.key,
|
label: localized(type.name, i18n.language) || type.key,
|
||||||
}))}
|
}))}
|
||||||
value={urlFilter.licenseTypeId ?? null}
|
value={urlFilter.licenseTypeId ?? null}
|
||||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
onChange={(v) => changeType(v ?? undefined)}
|
||||||
clearable
|
clearable
|
||||||
w={220}
|
w={220}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -398,8 +398,11 @@ export const licensingApi = baseApi
|
|||||||
* claim or a decision refreshes the badges along with the list and the
|
* claim or a decision refreshes the badges along with the list and the
|
||||||
* numbers can never drift from what the grid is showing.
|
* numbers can never drift from what the grid is showing.
|
||||||
*/
|
*/
|
||||||
getQueueCounts: builder.query<QueueCounts, void>({
|
getQueueCounts: builder.query<QueueCounts, string | void>({
|
||||||
query: () => ({ url: '/license-application-review/counts' }),
|
query: (licenseTypeKey) => ({
|
||||||
|
url: '/license-application-review/counts',
|
||||||
|
params: licenseTypeKey ? { licenseTypeKey } : undefined,
|
||||||
|
}),
|
||||||
providesTags: () => [listTag('ApplicationQueue')],
|
providesTags: () => [listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ export interface LicenseType {
|
|||||||
*/
|
*/
|
||||||
requiresOperatorMode: boolean;
|
requiresOperatorMode: boolean;
|
||||||
formSchema: { sections: FormSectionConfig[] };
|
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;
|
isActive: boolean;
|
||||||
/** Display order set by EMA; lower comes first. */
|
/** Display order set by EMA; lower comes first. */
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
@@ -382,6 +387,9 @@ export type QueueSortField =
|
|||||||
| "dueAt"
|
| "dueAt"
|
||||||
| "claimedAt";
|
| "claimedAt";
|
||||||
|
|
||||||
|
/** Review → evaluation → (inspection) → approval, or the short registration course. */
|
||||||
|
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
|
||||||
|
|
||||||
/** Row counts behind the queue's saved-view tabs. */
|
/** Row counts behind the queue's saved-view tabs. */
|
||||||
export interface QueueCounts {
|
export interface QueueCounts {
|
||||||
unassigned: number;
|
unassigned: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user