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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:36:25 -03:00
parent 5f8218b09a
commit 31e9db2d02
15 changed files with 546 additions and 8 deletions

View file

@ -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)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<SlaAtRiskBanner />
<Box
component="main"

View file

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

View file

@ -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
}
/>
))}
</ul>

View file

@ -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<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
return (
<button
type="button"
onClick={() => onAcknowledge?.(item)}
disabled={acknowledging}
title="Acknowledge"
aria-label="Acknowledge SLA breach"
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
color: "var(--destructive)",
borderColor: "var(--destructive)",
}}
>
{acknowledging ? "Acknowledging…" : "Acknowledge"}
</button>
);
}
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
export function NotificationRow({
item,
unread,
onOpen,
onDismiss,
onAcknowledge,
acknowledging,
}: NotificationRowProps) {
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
<X size={13} aria-hidden />
</button>
)}
{item.rowType === "acknowledge" && (
<AcknowledgeButton
item={item}
onAcknowledge={onAcknowledge}
acknowledging={acknowledging}
/>
)}
</li>
);
}

View file

@ -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 (
<div
role="region"
aria-label="Work orders at risk of missing their response deadline"
className="flex shrink-0 items-center"
style={{
gap: 10,
padding: "6px 16px",
background: "var(--destructive)",
color: "var(--destructive-foreground)",
}}
>
<button
type="button"
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
title="View in Work Orders"
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
style={{ color: "inherit" }}
>
<span
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
>
<AlertTriangle size={14} className="shrink-0" aria-hidden />
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
{slaBannerText(shown)}
</span>
<ChevronRight size={14} className="shrink-0" aria-hidden />
</span>
</button>
<button
type="button"
onClick={() => dismissSlaBanner(ids)}
title="Dismiss"
aria-label="Dismiss SLA alert"
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
style={{ width: 22, height: 22, color: "inherit" }}
>
<X size={14} aria-hidden />
</button>
</div>
);
}

View file

@ -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)),
};

View file

@ -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<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
/** Records who acknowledged the missed response deadline in the work order's audit history. */
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
};

View file

@ -5,6 +5,11 @@ export type NotificationSession = {
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
/** At-risk work orders hidden from the SLA banner for this session. */
slaBannerDismissedIds: ReadonlySet<number>;
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<NotificationSession | null>(null);

View file

@ -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<string>, ids: string[]): ReadonlySet<string> {
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
if (ids.every((id) => current.has(id))) {
return current;
}
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
() => new Set(),
);
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
const toastedSlaIds = useRef(new Set<number>());
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 (

View file

@ -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<typeof severitySchema>;
@ -60,3 +72,4 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
export type NotificationItem = z.infer<typeof itemSchema>;
export type NotificationSection = z.infer<typeof sectionSchema>;
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;

View file

@ -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<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
feed ? withoutSlaBreach(feed, workOrderId) : feed,
);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
});
}

View file

@ -0,0 +1,20 @@
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): 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<SlaAtRiskWorkOrder, "id" | "number">[]): 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<SlaAtRiskWorkOrder, "id" | "number">): string {
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
}

View file

@ -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<typeof import("@/providers/auth-context")>()),
@ -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();

View file

@ -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<typeof atRisk>[]) {
return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk };
}
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
function renderBanner() {
return renderWithProviders(
<NotificationSessionProvider>
<SlaAtRiskBanner />
<LocationProbe />
</NotificationSessionProvider>,
{ 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",
});
});
});

View file

@ -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<NotificationItem> & Pick<NotificationItem, "id">,
): 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]);
});
});