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)` }}
>
+
{
- setAdvApplied(cloneAdvancedFilters(filters));
+ setAdvApplied(resolveAppliedAdvancedFilters(filters));
setPage(0);
};
diff --git a/src/components/notifications/notification-feed-actions.tsx b/src/components/notifications/notification-feed-actions.tsx
index 3df626e2..64938205 100644
--- a/src/components/notifications/notification-feed-actions.tsx
+++ b/src/components/notifications/notification-feed-actions.tsx
@@ -1,3 +1,4 @@
+import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
@@ -6,7 +7,8 @@ type NotificationFeedActionsProps = {
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 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/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..fc36045b 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,12 +58,25 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
filters.internalOnly,
filters.hasUplift,
filters.unscheduledOnly,
+ Boolean(filters.workOrderIds?.size),
];
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`;
}
@@ -120,6 +135,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..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";
@@ -133,6 +134,13 @@ function mapSearchFacetParams(
};
}
+/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
+function mapWorkOrderIds(ids: Set | undefined): string | 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. */
export function toBoardQueryParams(
bar: WorkOrderBarFilters,
@@ -226,6 +234,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..43a9a000
--- /dev/null
+++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx
@@ -0,0 +1,172 @@
+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 { 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,
+ 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. */
+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 (
+ <>
+
+
+
+
+ {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("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",
+ 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/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 ;
+}
+
+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]);
+ });
+});
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..638f9609
--- /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[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX);
+ });
+});