Compare commits

...

2 commits

Author SHA1 Message Date
Alexandre Brandizzi
7de34d359e Merge branch 'feat/ab/wo-ids-filter' into feat/ab/sh-295-sla-alerts 2026-09-25 11:52:57 -03:00
Alexandre Brandizzi
31e9db2d02 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.
2026-09-25 11:36:25 -03:00
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]);
});
});