mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 08:33:11 +00:00
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:
parent
5f8218b09a
commit
31e9db2d02
15 changed files with 546 additions and 8 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
90
src/components/notifications/sla-at-risk-banner.tsx
Normal file
90
src/components/notifications/sla-at-risk-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
});
|
||||
}
|
||||
20
src/domain/notifications/utils/sla-alert-copy.ts
Normal file
20
src/domain/notifications/utils/sla-alert-copy.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
148
src/test/components/notifications/sla-at-risk-banner.test.tsx
Normal file
148
src/test/components/notifications/sla-at-risk-banner.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue