From e052dcb37a4af7f0ab2aa5069553d9db390ede39 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:06:09 -0300 Subject: [PATCH 1/4] feat(workorders): filter board to an exact work-order id set /workorders?ids=101,205,318 opens the board on exactly those work orders through board/search's ids filter. The id set replaces every other drill-down filter, a chip reads "Showing N selected work orders" and clearing it returns to the weekly board. buildWorkOrderIdsHref builds the link for alerts and linked-work-order lists. --- .../types/work-order-advanced-filters.ts | 3 + .../work-orders/types/work-order-board.ts | 2 + .../utils/advanced-filter-chip-builders.ts | 14 +++ .../utils/advanced-filters-utils.ts | 4 + .../work-orders/utils/board-query-params.ts | 6 + .../utils/dashboard-drilldown-params.ts | 7 ++ .../utils/work-order-ids-filter.ts | 26 ++++ .../work-order-ids-drilldown.test.tsx | 112 ++++++++++++++++++ .../utils/work-order-ids-filter.test.ts | 33 ++++++ 9 files changed, 207 insertions(+) create mode 100644 src/domain/work-orders/utils/work-order-ids-filter.ts create mode 100644 src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx create mode 100644 src/test/domain/work-orders/utils/work-order-ids-filter.test.ts diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index 66f831dc..e440da76 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -46,6 +46,8 @@ export interface WorkOrderAdvancedFilters { internalOnly: boolean; hasUplift: boolean; upliftStatuses: Set; + /** Exact work orders to show; when non-empty the server ignores every other filter. */ + workOrderIds: Set; } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { @@ -70,6 +72,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { internalOnly: false, hasUplift: false, upliftStatuses: new Set(), + workOrderIds: new Set(), }; export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 317e7cad..978ef988 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -326,6 +326,8 @@ export interface WorkOrderBoardSearchQueryParams { hasUplift?: boolean; upliftStatuses?: string[]; myWorkOrders?: boolean; + /** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */ + ids?: string; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortDir?: "asc" | "desc" | string; } diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index c7222b82..f1c43846 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -306,3 +306,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder clear: clearUplift, }); } + +export function appendWorkOrderIdsChip( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + const count = filters.workOrderIds?.size ?? 0; + if (count === 0) return; + chips.push({ + key: "workOrderIds", + group: "Work orders", + value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`, + clear: (current) => ({ ...current, workOrderIds: new Set() }), + }); +} diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 871bd0cd..9bcf6633 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -12,6 +12,7 @@ import { appendSeverityChip, appendStatusPmVendorDocChips, appendUpliftChip, + appendWorkOrderIdsChip, type AdvancedFilterChip, } from "@/domain/work-orders/utils/advanced-filter-chip-builders"; import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -33,6 +34,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte docs: new Set(filters.docs), flagColors: new Set(filters.flagColors ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []), + workOrderIds: new Set(filters.workOrderIds ?? []), unscheduledOnly: Boolean(filters.unscheduledOnly), }; } @@ -56,6 +58,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool filters.internalOnly, filters.hasUplift, filters.unscheduledOnly, + Boolean(filters.workOrderIds?.size), ]; export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { @@ -120,6 +123,7 @@ export function buildAdvancedFilterChips( appendIndicatorChips(chips, filters); appendFlagAndInternalChips(chips, filters); appendUpliftChip(chips, filters); + appendWorkOrderIdsChip(chips, filters); return chips; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 998d0605..27ef48c8 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -133,6 +133,11 @@ function mapSearchFacetParams( }; } +/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ +function mapWorkOrderIds(ids: Set | undefined): string | undefined { + return ids?.size ? [...ids].join(",") : undefined; +} + /** Build GET /board query from weekly filter bar. */ export function toBoardQueryParams( bar: WorkOrderBarFilters, @@ -226,6 +231,7 @@ export function toBoardSearchQueryParams(input: { docStatuses: mapDocStatuses(filters.docs), unscheduledOnly: filters.unscheduledOnly || undefined, ...mapSearchFacetParams(filters), + ids: mapWorkOrderIds(filters.workOrderIds), sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", }; diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index bb868e51..2334b9b6 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter"; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -63,6 +64,12 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { export function parseDashboardDrilldownParams( params: URLSearchParams, ): WorkOrderAdvancedFilters | null { + // An exact id set replaces every other filter: nothing else may hide one of those work orders. + const workOrderIds = parseWorkOrderIdsParam(params.get("ids")); + if (workOrderIds.length > 0) { + return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) }; + } + const dateFrom = (params.get("dateFrom") ?? "").trim(); const dateTo = (params.get("dateTo") ?? "").trim(); const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); diff --git a/src/domain/work-orders/utils/work-order-ids-filter.ts b/src/domain/work-orders/utils/work-order-ids-filter.ts new file mode 100644 index 00000000..fc60a7b3 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-ids-filter.ts @@ -0,0 +1,26 @@ +/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */ +export const WORK_ORDER_IDS_MAX = 200; + +/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */ +export function normalizeWorkOrderIds(values: Iterable): number[] { + const ids = new Set(); + for (const value of values) { + const text = typeof value === "number" ? String(value) : value.trim(); + if (!/^\d+$/.test(text)) continue; + const id = Number(text); + if (Number.isSafeInteger(id) && id > 0) ids.add(id); + if (ids.size === WORK_ORDER_IDS_MAX) break; + } + return [...ids]; +} + +/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */ +export function parseWorkOrderIdsParam(raw: string | null): number[] { + return raw ? normalizeWorkOrderIds(raw.split(",")) : []; +} + +/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */ +export function buildWorkOrderIdsHref(ids: number[]): string { + const normalized = normalizeWorkOrderIds(ids); + return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders"; +} diff --git a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx new file mode 100644 index 00000000..24ae3255 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -0,0 +1,112 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { describe, expect, it } from "vitest"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; +import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; +import { renderWithProviders } from "@/test/test-utils"; + +/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */ +function BoardFilterHarness() { + const filters = useWorkOrdersListFilters(); + useDashboardDrilldownFilters(filters.applyAdvancedFilters); + const location = useLocation(); + const params = buildResolvedBoardSearchParams({ + advApplied: filters.advApplied, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + return ( + <> + + {params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))} + + {location.search} + {filters.advApplied != null && ( + + )} + + ); +} + +function searchParams(): Record | "weekly board" { + const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? ""; + return text === "weekly board" ? text : (JSON.parse(text) as Record); +} + +describe("Work Orders exact id filter", () => { + it("opens the board on exactly the linked work orders and shows the selection chip", async () => { + renderWithProviders(, { + route: "/workorders?ids=4,9,4", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("4,9"); + // No saved or default facet travels with the id set. + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + expect(params.types).toBeUndefined(); + + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + // The applied link params are consumed, like the dashboard drill-downs. + expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/); + }); + + it("returns to the weekly board when the selection chip is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: buildWorkOrderIdsHref([12]), + withAuth: false, + }); + + expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove filter" })); + + expect(searchParams()).toBe("weekly board"); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("ignores other drill-down params sent alongside the id set", async () => { + renderWithProviders(, { + route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("5"); + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + }); + + it("keeps the regular drill-down filters when no ids are given", async () => { + renderWithProviders(, { + route: "/workorders?statuses=Scheduled", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.statuses).toEqual([expect.any(Number)]); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts new file mode 100644 index 00000000..f69f87ae --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + WORK_ORDER_IDS_MAX, + buildWorkOrderIdsHref, + normalizeWorkOrderIds, + parseWorkOrderIdsParam, +} from "@/domain/work-orders/utils/work-order-ids-filter"; + +describe("work-order ids filter", () => { + it("builds the Work Orders link for an exact id set", () => { + expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318"); + }); + + it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => { + expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101"); + expect(buildWorkOrderIdsHref([])).toBe("/workorders"); + }); + + it("reads the ids param, ignoring malformed tokens", () => { + expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]); + expect(parseWorkOrderIdsParam(null)).toEqual([]); + expect(parseWorkOrderIdsParam("")).toEqual([]); + }); + + it("keeps at most the backend limit of distinct ids", () => { + const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1); + const normalized = normalizeWorkOrderIds([...ids, 1, 2]); + + expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX); + expect(normalized[0]).toBe(1); + expect(normalized.at(-1)).toBe(WORK_ORDER_IDS_MAX); + }); +}); From 5f8218b09a673c53717ef89af223f49fe9f3156a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:11:09 -0300 Subject: [PATCH 2/4] test(workorders): avoid Array.prototype.at outside the ES2020 lib --- src/test/domain/work-orders/utils/work-order-ids-filter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts index f69f87ae..638f9609 100644 --- a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts +++ b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts @@ -28,6 +28,6 @@ describe("work-order ids filter", () => { expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX); expect(normalized[0]).toBe(1); - expect(normalized.at(-1)).toBe(WORK_ORDER_IDS_MAX); + expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX); }); }); From 31e9db2d02c6d92c30dc723014185deaaf990d2b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:36:25 -0300 Subject: [PATCH 3/4] feat(notifications): SLA at-risk banner, 50% toast and breach acknowledgement Dispatchers see a one-line banner and a one-time toast while a Reactive/Emergency work order is past half of its SEV response window; the banner opens the board on exactly those work orders and dismisses for the session. Missed deadlines stay in the notification center until acknowledged, and Clear all skips them. --- src/app/(protected)/_layout.tsx | 2 + .../notification-feed-actions.tsx | 4 +- .../notifications/notification-feed-list.tsx | 5 + .../notifications/notification-row.tsx | 45 +++++- .../notifications/sla-at-risk-banner.tsx | 90 +++++++++++ .../notifications/use-notification-center.ts | 10 ++ .../notifications/api/notifications-api.ts | 5 +- .../session/notification-session-context.ts | 5 + .../session/notification-session-provider.tsx | 37 ++++- .../notifications/types/notification-feed.ts | 13 ++ .../use-cases/use-acknowledge-sla-breach.ts | 44 ++++++ .../notifications/utils/sla-alert-copy.ts | 20 +++ .../notification-center.test.tsx | 57 ++++++- .../notifications/sla-at-risk-banner.test.tsx | 148 ++++++++++++++++++ .../use-acknowledge-sla-breach.test.ts | 69 ++++++++ 15 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 src/components/notifications/sla-at-risk-banner.tsx create mode 100644 src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts create mode 100644 src/domain/notifications/utils/sla-alert-copy.ts create mode 100644 src/test/components/notifications/sla-at-risk-banner.test.tsx create mode 100644 src/test/domain/notifications/use-acknowledge-sla-breach.test.ts diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index a5826b19..bffc9e3d 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; +import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner"; import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; export default function AdminShellLayout() { @@ -40,6 +41,7 @@ export default function AdminShellLayout() { sx={{ width: `calc(100% - ${sidebarWidth}px)` }} > + 0; - const hasItems = center.sections.length > 0; + // Rows that need acknowledging are never cleared, so they alone do not enable "Clear all". + const hasItems = center.sections.some((section) => section.items.some(isDismissable)); return ( <> diff --git a/src/components/notifications/notification-feed-list.tsx b/src/components/notifications/notification-feed-list.tsx index 48215231..701de2c1 100644 --- a/src/components/notifications/notification-feed-list.tsx +++ b/src/components/notifications/notification-feed-list.tsx @@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) { unread={center.unreadIds.has(item.id)} onOpen={center.open} onDismiss={center.dismiss} + onAcknowledge={center.acknowledge} + acknowledging={ + item.target.workOrderId != null && + item.target.workOrderId === center.acknowledgingWorkOrderId + } /> ))} diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index ed254ab7..a8f1e5f0 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -53,8 +53,37 @@ type NotificationRowProps = { unread: boolean; onOpen: (item: NotificationItem, url?: string) => void; onDismiss: (item: NotificationItem) => void; + onAcknowledge?: (item: NotificationItem) => void; + acknowledging?: boolean; }; +/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */ +function AcknowledgeButton({ + item, + onAcknowledge, + acknowledging = false, +}: Pick) { + return ( + + ); +} + /** A vendor conflict spans several work orders, so each one stays reachable from the row. */ function WorkOrderLinks({ item, onOpen }: Pick) { if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) { @@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick )} + {item.rowType === "acknowledge" && ( + + )} ); } diff --git a/src/components/notifications/sla-at-risk-banner.tsx b/src/components/notifications/sla-at-risk-banner.tsx new file mode 100644 index 00000000..d2f6aba1 --- /dev/null +++ b/src/components/notifications/sla-at-risk-banner.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from "react"; +import { AlertTriangle, ChevronRight, X } from "lucide-react"; +import { useNavigate } from "react-router"; +import { toast } from "react-toastify"; +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; +import { useNotificationSession } from "@/domain/notifications/session/notification-session-context"; +import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed"; +import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = []; + +/** Toasts each at-risk work order once per session, the first time the feed reports it. */ +function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) { + const { claimSlaToasts } = useNotificationSession(); + + useEffect(() => { + const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id))); + atRisk + .filter((workOrder) => claimed.has(workOrder.id)) + .forEach((workOrder) => + toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }), + ); + }, [atRisk, claimSlaToasts]); +} + +/** + * One line at the top of every page while any of the user's Reactive/Emergency work orders is past + * half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing + * hides them for this session, and a work order that becomes at risk later brings the banner back. + */ +export function SlaAtRiskBanner() { + const navigate = useNavigate(); + const { data } = useNotificationFeed(); + const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession(); + const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS; + useSlaHalfwayToasts(atRisk); + + const shown = useMemo( + () => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)), + [atRisk, slaBannerDismissedIds], + ); + if (shown.length === 0) { + return null; + } + + const ids = shown.map((workOrder) => workOrder.id); + return ( +
+ + +
+ ); +} diff --git a/src/components/notifications/use-notification-center.ts b/src/components/notifications/use-notification-center.ts index a466b257..ccc6a491 100644 --- a/src/components/notifications/use-notification-center.ts +++ b/src/components/notifications/use-notification-center.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useNavigate } from "react-router"; import type { NotificationItem } from "@/domain/notifications/types/notification-feed"; import { useNotificationSession } from "@/domain/notifications/session/notification-session-context"; +import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach"; import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed"; import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url"; import { @@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) { [items, readIds], ); + const acknowledgeBreach = useAcknowledgeSlaBreach(); + const acknowledge = (item: NotificationItem) => { + if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return; + acknowledgeBreach.mutate(item.target.workOrderId); + }; + const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null; + const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => { markRead([item.id]); if (url == null) return; @@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) { error: feed.error, open, dismiss: (item: NotificationItem) => dismiss([item.id]), + acknowledge, + acknowledgingWorkOrderId, dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)), markAllRead: () => markRead(items.map((item) => item.id)), }; diff --git a/src/domain/notifications/api/notifications-api.ts b/src/domain/notifications/api/notifications-api.ts index 034556f2..d17bd877 100644 --- a/src/domain/notifications/api/notifications-api.ts +++ b/src/domain/notifications/api/notifications-api.ts @@ -1,4 +1,4 @@ -import { apiGet } from "@/api/api"; +import { apiGet, apiPostNoContent } from "@/api/api"; import { notificationFeedSchema, type NotificationFeed, @@ -7,4 +7,7 @@ import { export const notificationsApi = { feed: async (): Promise => notificationFeedSchema.parse(await apiGet("notifications")), + /** Records who acknowledged the missed response deadline in the work order's audit history. */ + acknowledgeSlaBreach: (workOrderId: number): Promise => + apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`), }; diff --git a/src/domain/notifications/session/notification-session-context.ts b/src/domain/notifications/session/notification-session-context.ts index c5697e97..01ddac7c 100644 --- a/src/domain/notifications/session/notification-session-context.ts +++ b/src/domain/notifications/session/notification-session-context.ts @@ -5,6 +5,11 @@ export type NotificationSession = { readIds: ReadonlySet; dismiss: (ids: string[]) => void; markRead: (ids: string[]) => void; + /** At-risk work orders hidden from the SLA banner for this session. */ + slaBannerDismissedIds: ReadonlySet; + dismissSlaBanner: (workOrderIds: number[]) => void; + /** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */ + claimSlaToasts: (workOrderIds: number[]) => number[]; }; export const NotificationSessionContext = createContext(null); diff --git a/src/domain/notifications/session/notification-session-provider.tsx b/src/domain/notifications/session/notification-session-provider.tsx index 87bae800..fc389b5d 100644 --- a/src/domain/notifications/session/notification-session-provider.tsx +++ b/src/domain/notifications/session/notification-session-provider.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { NotificationSessionContext } from "./notification-session-context"; -function addAll(current: ReadonlySet, ids: string[]): ReadonlySet { +function addAll(current: ReadonlySet, ids: T[]): ReadonlySet { if (ids.every((id) => current.has(id))) { return current; } @@ -17,6 +17,11 @@ function addAll(current: ReadonlySet, ids: string[]): ReadonlySet>(() => new Set()); const [readIds, setReadIds] = useState>(() => new Set()); + const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState>( + () => new Set(), + ); + // A ref, not state: claiming must be synchronous so a re-run effect can never toast twice. + const toastedSlaIds = useRef(new Set()); const dismiss = useCallback((ids: string[]) => { setDismissedIds((current) => addAll(current, ids)); @@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode const markRead = useCallback((ids: string[]) => { setReadIds((current) => addAll(current, ids)); }, []); + const dismissSlaBanner = useCallback((workOrderIds: number[]) => { + setSlaBannerDismissedIds((current) => addAll(current, workOrderIds)); + }, []); + const claimSlaToasts = useCallback((workOrderIds: number[]) => { + const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id)); + claimed.forEach((id) => toastedSlaIds.current.add(id)); + return claimed; + }, []); const value = useMemo( - () => ({ dismissedIds, readIds, dismiss, markRead }), - [dismissedIds, readIds, dismiss, markRead], + () => ({ + dismissedIds, + readIds, + dismiss, + markRead, + slaBannerDismissedIds, + dismissSlaBanner, + claimSlaToasts, + }), + [ + dismissedIds, + readIds, + dismiss, + markRead, + slaBannerDismissedIds, + dismissSlaBanner, + claimSlaToasts, + ], ); return ( diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts index fc5e7bdf..af242b17 100644 --- a/src/domain/notifications/types/notification-feed.ts +++ b/src/domain/notifications/types/notification-feed.ts @@ -50,9 +50,21 @@ const sectionSchema = z.object({ items: z.array(itemSchema), }); +/** A work order past half of its SEV response window, with the server-computed clock. */ +const slaAtRiskSchema = z.object({ + id: z.number(), + number: z.string().nullish(), + severity: z.number(), + startedAt: z.string(), + deadlineAt: z.string(), + percentElapsed: z.number(), +}); + export const notificationFeedSchema = z.object({ generatedAt: z.string(), sections: z.array(sectionSchema), + // Defaulted so a feed from a server without SLA alerts still parses. + slaAtRisk: z.array(slaAtRiskSchema).default([]), }); export type NotificationSeverity = z.infer; @@ -60,3 +72,4 @@ export type NotificationTarget = z.infer; export type NotificationItem = z.infer; export type NotificationSection = z.infer; export type NotificationFeed = z.infer; +export type SlaAtRiskWorkOrder = z.infer; diff --git a/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts new file mode 100644 index 00000000..fb1fc62e --- /dev/null +++ b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { notificationsApi } from "@/domain/notifications/api/notifications-api"; +import type { + NotificationFeed, + NotificationItem, +} from "@/domain/notifications/types/notification-feed"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function isBreachRowFor(workOrderId: number) { + return (item: NotificationItem) => + item.reason === "sla" && + item.rowType === "acknowledge" && + item.target.workOrderId === workOrderId; +} + +/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */ +export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed { + const matches = isBreachRowFor(workOrderId); + return { + ...feed, + sections: feed.sections.map((section) => { + const removed = section.items.filter(matches); + if (removed.length === 0) return section; + return { + ...section, + items: section.items.filter((item) => !matches(item)), + count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0), + }; + }), + }; +} + +export function useAcknowledgeSlaBreach() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId), + onSuccess: (_result, workOrderId) => { + queryClient.setQueryData(queryKeys.notifications.feed(), (feed) => + feed ? withoutSlaBreach(feed, workOrderId) : feed, + ); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }), + }); +} diff --git a/src/domain/notifications/utils/sla-alert-copy.ts b/src/domain/notifications/utils/sla-alert-copy.ts new file mode 100644 index 00000000..8f9e26a2 --- /dev/null +++ b/src/domain/notifications/utils/sla-alert-copy.ts @@ -0,0 +1,20 @@ +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; + +function workOrderLabel(workOrder: Pick): string { + return workOrder.number != null && workOrder.number !== "" + ? `WO #${workOrder.number}` + : `WO ${workOrder.id}`; +} + +/** One line whatever the count, as the top-of-app banner shows it. */ +export function slaBannerText(workOrders: Pick[]): string { + if (workOrders.length === 1) { + return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`; + } + return `${workOrders.length} work orders are at risk of missing their response deadline`; +} + +/** The one-time toast when a work order is first seen past half of its response window. */ +export function slaHalfwayToastText(workOrder: Pick): string { + return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`; +} diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx index 781ee098..8e169f44 100644 --- a/src/test/components/notifications/notification-center.test.tsx +++ b/src/test/components/notifications/notification-center.test.tsx @@ -9,8 +9,9 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti import { renderWithProviders } from "@/test/test-utils"; const apiGet = vi.hoisted(() => vi.fn()); +const apiPostNoContent = vi.hoisted(() => vi.fn()); -vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent })); vi.mock("@/providers/auth-context", async (importOriginal) => ({ ...(await importOriginal()), @@ -131,6 +132,8 @@ describe("notification center", () => { beforeEach(() => { apiGet.mockReset(); apiGet.mockResolvedValue(standardFeed); + apiPostNoContent.mockReset(); + apiPostNoContent.mockResolvedValue(undefined); }); it("requests the signed-in user's feed and counts every unread item on the bell", async () => { @@ -256,6 +259,58 @@ describe("notification center", () => { expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); }); + it("keeps Clear all disabled when only rows that need acknowledging are left", async () => { + apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow]))); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + + expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled(); + }); + + it("acknowledges a missed deadline through the API and removes the row", async () => { + apiGet.mockResolvedValue( + feed( + section("sla", "SLA at Risk", [acknowledgeRow]), + section("unassigned", "Unassigned", [unassigned]), + ), + ); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement; + const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" }); + expect(acknowledge).toHaveAttribute("title", "Acknowledge"); + expect(acknowledge).toHaveTextContent("Acknowledge"); + + apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned]))); + await user.click(acknowledge); + + expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge"); + await waitFor(() => + expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(), + ); + expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]); + }); + + it("keeps the row when the acknowledgement fails", async () => { + apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow]))); + apiPostNoContent.mockRejectedValue(new Error("boom")); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })); + + await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(), + ); + expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); + }); + it("opens the unassigned queue with the existing board filters and closes the panel", async () => { const user = userEvent.setup(); renderBell(); diff --git a/src/test/components/notifications/sla-at-risk-banner.test.tsx b/src/test/components/notifications/sla-at-risk-banner.test.tsx new file mode 100644 index 00000000..163b36b9 --- /dev/null +++ b/src/test/components/notifications/sla-at-risk-banner.test.tsx @@ -0,0 +1,148 @@ +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner"; +import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const apiGet = vi.hoisted(() => vi.fn()); +const toastWarn = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } })); + +const BANNER = "Work orders at risk of missing their response deadline"; + +function atRisk(id: number, number: string | null = String(1000 + id)) { + return { + id, + number, + severity: 1, + startedAt: "2026-09-18T10:00:00Z", + deadlineAt: "2026-09-18T12:00:00Z", + percentElapsed: 60, + }; +} + +function feed(...slaAtRisk: ReturnType[]) { + return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk }; +} + +function LocationProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderBanner() { + return renderWithProviders( + + + + , + { withAuth: false }, + ); +} + +describe("SLA at-risk banner", () => { + beforeEach(() => { + apiGet.mockReset(); + toastWarn.mockReset(); + }); + + it("stays hidden while no work order is past half of its response window", async () => { + apiGet.mockResolvedValue(feed()); + renderBanner(); + + await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications")); + expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument(); + expect(toastWarn).not.toHaveBeenCalled(); + }); + + it("names the single work order at risk", async () => { + apiGet.mockResolvedValue(feed(atRisk(101))); + renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + expect( + within(banner).getByRole("button", { + name: "WO #1101 is at risk of missing its response deadline", + }), + ).toBeInTheDocument(); + }); + + it("collapses several work orders to one line and opens Work Orders on exactly those", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318))); + const user = userEvent.setup(); + renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + const open = within(banner).getByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }); + expect(open).toHaveAttribute("title", "View in Work Orders"); + expect(within(banner).getAllByRole("button")).toHaveLength(2); + + await user.click(open); + + expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent( + "/workorders?ids=101,205,318", + ); + }); + + it("dismisses for this session only and returns when a new work order becomes at risk", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205))); + const user = userEvent.setup(); + const { queryClient, unmount } = renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" })); + expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument(); + + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318))); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + + const returned = await screen.findByRole("region", { name: BANNER }); + expect( + within(returned).getByRole("button", { + name: "WO #1318 is at risk of missing its response deadline", + }), + ).toBeInTheDocument(); + + unmount(); + renderBanner(); + expect( + await screen.findByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }), + ).toBeInTheDocument(); + }); + + it("toasts each work order once per session, however often the feed polls", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null))); + const { queryClient } = renderBanner(); + + await screen.findByRole("region", { name: BANNER }); + expect(toastWarn).toHaveBeenCalledTimes(2); + expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", { + toastId: "sla-at-risk-101", + }); + expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", { + toastId: "sla-at-risk-205", + }); + + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318))); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + await screen.findByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + + expect(toastWarn).toHaveBeenCalledTimes(3); + expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", { + toastId: "sla-at-risk-318", + }); + }); +}); diff --git a/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts new file mode 100644 index 00000000..5349faa8 --- /dev/null +++ b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { + NotificationFeed, + NotificationItem, +} from "@/domain/notifications/types/notification-feed"; +import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach"; + +function row( + overrides: Partial & Pick, +): NotificationItem { + return { + reason: "sla", + severity: "Critical", + rowType: "acknowledge", + title: "missed its response deadline", + count: 1, + triggeredAt: "2026-09-18T12:00:00Z", + target: { kind: "workOrder", workOrderId: 9, tab: "info" }, + workOrders: [], + ...overrides, + }; +} + +const breach9 = row({ id: "sla-breach-9" }); +const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } }); +const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" }); +const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" }); + +const feed: NotificationFeed = { + generatedAt: "2026-09-18T12:00:00Z", + slaAtRisk: [], + sections: [ + { + reason: "sla", + label: "SLA at Risk", + severity: "Critical", + count: 3, + items: [breach9, breach10, atRisk9], + }, + { + reason: "aveta-missing", + label: "Aveta Missing", + severity: "Medium", + count: 1, + items: [aveta9], + }, + ], +}; + +describe("withoutSlaBreach", () => { + it("drops only the acknowledged work order's breach row and lowers its section count", () => { + const next = withoutSlaBreach(feed, 9); + + expect(next.sections[0].items.map((item) => item.id)).toEqual([ + "sla-breach-10", + "sla-at-risk-9", + ]); + expect(next.sections[0].count).toBe(2); + expect(next.sections[1]).toBe(feed.sections[1]); + }); + + it("leaves the feed as it was when that work order has no breach row", () => { + const next = withoutSlaBreach(feed, 42); + + expect(next.sections).toEqual(feed.sections); + expect(next.sections[0]).toBe(feed.sections[0]); + }); +}); From 74dd1292a4e1861186391c50fe0619aff650389f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:52:27 -0300 Subject: [PATCH 4/4] fix(workorders): applying a panel filter replaces the exact id selection The server honours only the id set when one is sent, so a facet applied during an ids drill-down showed as an active chip while doing nothing. Applying any facet now drops the selection; applying the panel unchanged keeps it. The ids param is also capped at the server limit whatever built the set. --- .../_hooks/use-work-orders-list-filters.ts | 4 +- .../utils/advanced-filters-utils.ts | 12 ++++ .../work-orders/utils/board-query-params.ts | 5 +- .../work-order-ids-drilldown.test.tsx | 62 ++++++++++++++++++- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts index 811c9823..9a57c1de 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts @@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board"; import { clearAdvancedFilterGroup, - cloneAdvancedFilters, countAdvancedFilters, getInitialAdvancedFilters, + resolveAppliedAdvancedFilters, } from "@/domain/work-orders/utils/advanced-filters-utils"; import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params"; import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination"; @@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() { }; const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => { - setAdvApplied(cloneAdvancedFilters(filters)); + setAdvApplied(resolveAppliedAdvancedFilters(filters)); setPage(0); }; diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 9bcf6633..fc36045b 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -65,6 +65,18 @@ export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length; } +/** + * The server honours only the id set when one is sent, so an id selection and panel facets never + * combine: applying any facet replaces the selection with a normal filtered board. + */ +export function resolveAppliedAdvancedFilters( + filters: WorkOrderAdvancedFilters, +): WorkOrderAdvancedFilters { + const next = cloneAdvancedFilters(filters); + const withoutIds = { ...next, workOrderIds: new Set() }; + return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next; +} + function monthStart(y: number, m: number): string { return `${y}-${pad2(m + 1)}-01`; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 27ef48c8..c2e34373 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -12,6 +12,7 @@ import { } from "@/domain/work-orders/types/work-order-board"; import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; +import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter"; import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -135,7 +136,9 @@ function mapSearchFacetParams( /** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ function mapWorkOrderIds(ids: Set | undefined): string | undefined { - return ids?.size ? [...ids].join(",") : undefined; + // Normalized here too, so a caller that fills the set directly still stays within the server's limit. + const normalized = ids?.size ? normalizeWorkOrderIds(ids) : []; + return normalized.length > 0 ? normalized.join(",") : undefined; } /** Build GET /board query from weekly filter bar. */ diff --git a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx index 24ae3255..43a9a000 100644 --- a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -6,8 +6,13 @@ import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_c import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params"; -import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; +import { + buildWorkOrderIdsHref, + WORK_ORDER_IDS_MAX, +} from "@/domain/work-orders/utils/work-order-ids-filter"; import { renderWithProviders } from "@/test/test-utils"; /** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */ @@ -32,6 +37,15 @@ function BoardFilterHarness() { {params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))} {location.search} + + {filters.advApplied != null && ( { expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); }); + it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: "/workorders?ids=4,9", + withAuth: false, + }); + + expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply rescheduled only" })); + + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.rescheduled).toBe(true); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("keeps the selection when the panel is applied without a filter", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: "/workorders?ids=4,9", + withAuth: false, + }); + + expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply unchanged" })); + + expect((searchParams() as Record).ids).toBe("4,9"); + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + }); + + it("never sends more ids than the server accepts, however the selection was built", () => { + const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1)); + const params = buildResolvedBoardSearchParams({ + advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids }, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX); + }); + it("ignores other drill-down params sent alongside the id set", async () => { renderWithProviders(, { route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1",