SH-292: Notification Center bell panel and feed page

Header bell panel and /notifications page share one feed from GET /api/notifications: labeled sections with live counts, unread styling, session-only dismiss and clear all (acknowledge rows excluded), and deep links to the unassigned queue and a work order's tab via ?wo=&tab=.
This commit is contained in:
Codex Review Integration 2026-09-18 12:55:54 -03:00
parent d6510b262e
commit dcf944d7dc
29 changed files with 1185 additions and 154 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 { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
const theme = useTheme();
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<NotificationSessionProvider>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<Outlet />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />
</Box>
</Box>
</Box>
</Box>
</NotificationSessionProvider>
</ProtectedRoute>
);
}

View file

@ -1,104 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { Link } from "react-router";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { Box } from "@mui/material";
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
import { PageHeader } from "@/components/ui/page-header";
export default function NotificationsPage() {
const {
data = [],
isLoading,
error,
} = useQuery({
queryKey: ["vendor-operations", "notifications"],
queryFn: vendorOperationsApi.notifications,
refetchInterval: 30_000,
});
const groups = data.reduce((result, item) => {
const items = result.get(item.group) ?? [];
items.push(item);
result.set(item.group, items);
return result;
}, new Map<string, typeof data>());
const center = useNotificationCenter();
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Items requiring attention
</Typography>
<Typography color="text.secondary">
Medium-severity vendor reminders refresh automatically. They do not create banners or
toasts.
</Typography>
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="Notifications"
actions={
<div className="flex items-center gap-3">
<NotificationFeedActions center={center} />
</div>
}
/>
<Box className="max-w-3xl px-5 pb-5">
<NotificationFeedList center={center} />
</Box>
{isLoading && <CircularProgress aria-label="Loading notifications" />}
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Paper variant="outlined" className="p-6">
<Typography>No vendor alerts require attention.</Typography>
</Paper>
)}
{[...groups.entries()].map(([group, items]) => (
<Paper key={group} variant="outlined" className="p-4">
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
<Typography variant="h6">{group}</Typography>
<Chip label={items.length} size="small" />
</Stack>
<Stack spacing={2}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" className="p-3">
<Stack
direction={{ xs: "column", md: "row" }}
sx={{ gap: 2, justifyContent: "space-between" }}
>
<Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip label={item.severity} color="warning" size="small" />
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
</Stack>
<Typography color="text.secondary" className="mt-1">
{item.workOrderNumber ??
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
</Typography>
{item.scheduledAt != null && (
<Typography variant="caption">
Scheduled {new Date(item.scheduledAt).toLocaleString()}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{item.action != null && (
<Button component={Link} to={item.action.href} variant="contained">
{item.action.label}
</Button>
)}
{item.actions?.map((action) => (
<Button
key={action.href}
component={Link}
to={action.href}
variant="outlined"
>
{action.label}
</Button>
))}
</Stack>
</Stack>
</Paper>
))}
</Stack>
</Paper>
))}
</Box>
);
}

View file

@ -27,6 +27,8 @@ type UseSlideOverEditStateArgs = {
onClose: () => void;
closeDisabled: boolean;
setTab: (tab: SlideOverTab) => void;
/** Tab to land on when a work order opens outside edit mode. */
initialTab?: SlideOverTab;
};
export function useSlideOverEditState({
@ -37,6 +39,7 @@ export function useSlideOverEditState({
onClose,
closeDisabled,
setTab,
initialTab = "info",
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
@ -59,7 +62,7 @@ export function useSlideOverEditState({
setDraft(row);
setBaseline(row);
setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab("info");
setTab(editMode ? "info" : initialTab);
setShowUnsaved(false);
setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.

View file

@ -35,6 +35,7 @@ type UseWorkOrderSlideOverArgs = {
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
};
type CompletionUploadMutate = (
@ -112,6 +113,7 @@ export function useWorkOrderSlideOver({
onSave,
onClose,
saving,
initialTab,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
@ -160,6 +162,7 @@ export function useWorkOrderSlideOver({
onClose,
closeDisabled,
setTab,
initialTab,
});
const uploadCompletionPdf = (file: File) => {

View file

@ -11,7 +11,7 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -35,6 +35,7 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
};
export function WorkOrderSlideOver({
@ -52,8 +53,9 @@ export function WorkOrderSlideOver({
onDuplicateFound,
onClose,
saving,
initialTab,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving, initialTab });
const { infoSource, activeDraft } = state;
return (

View file

@ -51,6 +51,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
users={users}
sites={locations}
vendors={vendors}

View file

@ -18,12 +18,14 @@ export function useDashboardDrilldownFilters(
}, [onApply]);
useEffect(() => {
if (appliedRef.current) {
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
// The applied params were cleared; a later drilldown link on this mounted page applies too.
appliedRef.current = false;
return;
}
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
if (appliedRef.current) {
return;
}

View file

@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
parseWorkOrderDeepLink,
withoutWorkOrderDeepLink,
} from "@/domain/work-orders/utils/work-order-deep-link";
type OpenSlideOver = (row: WorkOrderTableRow, edit: boolean, tab: SlideOverTab) => void;
/**
* Opens `?wo=<id>&tab=<tab>` in the slide-over once that work order's detail loads, whatever week
* the board is showing, then removes the params so closing the slide-over does not reopen it.
*/
export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void {
const [searchParams, setSearchParams] = useSearchParams();
const link = parseWorkOrderDeepLink(searchParams);
const workOrderId = link?.workOrderId;
const tab = link?.tab ?? "info";
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
const onOpenRef = useRef(onOpen);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
if (workOrderId == null) {
return;
}
const loaded = data != null && String(data.info.id) === workOrderId;
if (!loaded && error == null) {
return;
}
if (loaded) {
onOpenRef.current(data.info, false, tab);
}
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
}, [workOrderId, tab, data, error, searchParams, setSearchParams]);
}

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderLocations,
@ -21,6 +22,7 @@ export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -74,9 +76,14 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
const handleOpenSlideOver = (
row: WorkOrderTableRow,
edit = false,
tab: SlideOverTab = "info",
) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
};
const handleCloseSlideOver = () => {
@ -130,6 +137,7 @@ export function useWorkOrdersListPage() {
wizardOpen,
setWizardOpen,
slideOverEdit,
slideOverTab,
confirmCancel,
setConfirmCancel,
confirmComplete,

View file

@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
const page = useWorkOrdersListPage();
const { filters, tableData } = page;
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
useWorkOrderDeepLink(page.handleOpenSlideOver);
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">

View file

@ -15,6 +15,7 @@ import {
} from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
@ -97,6 +98,8 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
<Box sx={{ flex: 1 }} />
<NotificationBell />
<Box
component="button"
type="button"

View file

@ -0,0 +1,98 @@
import { useState } from "react";
import { Box, Drawer } from "@mui/material";
import { Bell, X } from "lucide-react";
import { Text } from "@/components/ui/text";
import { NotificationFeedActions } from "./notification-feed-actions";
import { NotificationFeedList } from "./notification-feed-list";
import { useNotificationCenter } from "./use-notification-center";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const center = useNotificationCenter(() => setOpen(false));
const unreadCount = center.unreadIds.size;
return (
<>
<Box
component="button"
type="button"
onClick={() => setOpen(true)}
title="Notifications"
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
sx={{
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 38,
height: 38,
borderRadius: "6px",
border: "none",
cursor: "pointer",
overflow: "visible",
backgroundColor: "var(--color-header-surface)",
color: "var(--color-header-icon)",
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
}}
>
<Bell size={18} aria-hidden />
{unreadCount > 0 && (
<span
aria-hidden
className="flex items-center justify-center"
style={{
position: "absolute",
top: 2,
right: 2,
minWidth: 16,
height: 16,
padding: "0 4px",
borderRadius: 9999,
background: "var(--destructive)",
color: "var(--destructive-foreground)",
fontSize: 10,
fontWeight: 600,
lineHeight: 1,
}}
>
{unreadCount}
</span>
)}
</Box>
<Drawer
anchor="right"
open={open}
onClose={() => setOpen(false)}
slotProps={{ paper: { sx: { width: 420, background: "var(--color-bg-surface)" } } }}
>
<div
className="flex shrink-0 items-center gap-2"
style={{ padding: "16px 20px", borderBottom: "1px solid var(--color-border)" }}
>
<Text
as="h2"
variant="title"
sx={{ flex: 1, fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)" }}
>
Notifications
</Text>
<NotificationFeedActions center={center} />
<button
type="button"
onClick={() => setOpen(false)}
title="Close"
aria-label="Close notifications"
className="shrink-0 cursor-pointer transition-opacity hover:opacity-70"
style={{ color: "var(--color-text-muted)" }}
>
<X size={16} aria-hidden />
</button>
</div>
<div className="flex-1 overflow-y-auto" style={{ padding: "8px 20px 20px" }}>
<NotificationFeedList center={center} />
</div>
</Drawer>
</>
);
}

View file

@ -0,0 +1,33 @@
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
center: NotificationCenter;
};
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
const hasItems = center.sections.length > 0;
return (
<>
<button
type="button"
onClick={center.markAllRead}
disabled={!hasUnread}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)" }}
>
Mark all read
</button>
<button
type="button"
onClick={center.dismissAll}
disabled={!hasItems}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-text-muted)" }}
>
Clear all
</button>
</>
);
}

View file

@ -0,0 +1,89 @@
import { CircularProgress } from "@mui/material";
import { Inbox } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { NotificationCenter } from "./use-notification-center";
import { NotificationRow } from "./notification-row";
type NotificationFeedListProps = {
center: NotificationCenter;
};
export function NotificationFeedList({ center }: NotificationFeedListProps) {
if (center.isLoading) {
return (
<div className="flex justify-center py-16">
<CircularProgress size={24} aria-label="Loading notifications" />
</div>
);
}
if (center.error != null) {
return (
<Text variant="error" className="py-6">
Notifications could not be loaded. They will retry automatically.
</Text>
);
}
if (center.sections.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16">
<Inbox size={24} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text variant="description" sx={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Nothing needs your attention
</Text>
</div>
);
}
return (
<div>
{center.sections.map((section) => (
<section
key={section.reason}
aria-label={`${section.label}, ${section.count}`}
style={{ marginTop: 16 }}
>
<div className="flex items-center gap-2" style={{ marginBottom: 4 }}>
<Text
as="h3"
variant="label"
sx={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--color-text-muted)",
}}
>
{section.label}
</Text>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: "var(--color-text-secondary)",
background: "var(--color-bg-muted)",
borderRadius: "var(--radius-sm)",
padding: "1px 6px",
}}
>
{section.count}
</span>
</div>
<ul className="m-0 list-none p-0">
{section.items.map((item) => (
<NotificationRow
key={item.id}
item={item}
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
/>
))}
</ul>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,93 @@
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
Bell,
Clock,
Inbox,
MessageSquare,
ShieldAlert,
Wrench,
X,
} from "lucide-react";
import { Text } from "@/components/ui/text";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
const REASON_ICONS: Record<string, LucideIcon> = {
"severity-critical": AlertTriangle,
sla: AlertTriangle,
mentions: MessageSquare,
unassigned: Inbox,
"due-soon": Clock,
"no-vendor": Wrench,
"aveta-missing": ShieldAlert,
activity: MessageSquare,
"vendor-conflict": Wrench,
};
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
onOpen: (item: NotificationItem) => void;
onDismiss: (item: NotificationItem) => void;
};
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
const Icon = REASON_ICONS[item.reason] ?? Bell;
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
padding: "10px 8px",
marginInline: -8,
borderRadius: "var(--radius-md)",
borderBottom: "1px solid var(--color-bg-muted)",
}}
data-unread={unread}
>
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
{isDismissable(item) && (
<button
type="button"
onClick={() => onDismiss(item)}
title="Dismiss"
aria-label="Dismiss notification"
className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
>
<X size={13} aria-hidden />
</button>
)}
</li>
);
}

View file

@ -0,0 +1,47 @@
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 { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
isDismissable,
visibleNotificationSections,
} from "@/domain/notifications/utils/visible-notification-sections";
export function useNotificationCenter(onNavigate?: () => void) {
const navigate = useNavigate();
const feed = useNotificationFeed();
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
const sections = useMemo(
() => visibleNotificationSections(feed.data?.sections ?? [], dismissedIds),
[feed.data, dismissedIds],
);
const items = useMemo(() => sections.flatMap((section) => section.items), [sections]);
const unreadIds = useMemo(
() => new Set(items.filter((item) => !readIds.has(item.id)).map((item) => item.id)),
[items, readIds],
);
const open = (item: NotificationItem) => {
markRead([item.id]);
const url = notificationTargetUrl(item.target);
if (url == null) return;
onNavigate?.();
navigate(url);
};
return {
sections,
unreadIds,
isLoading: feed.isLoading,
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};
}
export type NotificationCenter = ReturnType<typeof useNotificationCenter>;

View file

@ -0,0 +1,10 @@
import { apiGet } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
} from "@/domain/notifications/types/notification-feed";
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
};

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
export type NotificationSession = {
dismissedIds: ReadonlySet<string>;
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
export function useNotificationSession(): NotificationSession {
const session = useContext(NotificationSessionContext);
if (!session) {
throw new Error("useNotificationSession must be used within a NotificationSessionProvider");
}
return session;
}

View file

@ -0,0 +1,38 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
if (ids.every((id) => current.has(id))) {
return current;
}
const next = new Set(current);
ids.forEach((id) => next.add(id));
return next;
}
/**
* Dismissed and read notification ids for this app session only. Nothing is persisted, so a
* reload starts over and a condition that still holds shows up again.
*/
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
}, []);
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
);
return (
<NotificationSessionContext.Provider value={value}>
{children}
</NotificationSessionContext.Provider>
);
}

View file

@ -0,0 +1,62 @@
import { z } from "zod";
/** Fixed reason order; it breaks ties between sections of equal severity. */
export const NOTIFICATION_REASON_ORDER = [
"severity-critical",
"sla",
"mentions",
"unassigned",
"due-soon",
"no-vendor",
"aveta-missing",
"activity",
"vendor-conflict",
] as const;
export const NOTIFICATION_SEVERITY_RANK = {
Critical: 3,
High: 2,
Medium: 1,
Low: 0,
} as const;
const severitySchema = z.enum(["Critical", "High", "Medium", "Low"]);
const targetSchema = z.object({
kind: z.enum(["workOrder", "queue"]),
workOrderId: z.number().nullish(),
tab: z.string().nullish(),
queue: z.string().nullish(),
});
const itemSchema = z.object({
id: z.string(),
reason: z.string(),
severity: severitySchema,
// "acknowledge" rows (a missed response deadline) never take the normal dismiss.
rowType: z.enum(["dismissable", "acknowledge"]),
title: z.string(),
count: z.number(),
triggeredAt: z.string(),
target: targetSchema,
workOrders: z.array(z.object({ id: z.number(), number: z.string().nullish() })),
});
const sectionSchema = z.object({
reason: z.string(),
label: z.string(),
severity: severitySchema,
count: z.number(),
items: z.array(itemSchema),
});
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
});
export type NotificationSeverity = z.infer<typeof severitySchema>;
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>;

View file

@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
import { queryKeys } from "@/infra/query-key/query-key";
/** Live conditions are recomputed server-side on every read, so polling keeps counts current. */
export const NOTIFICATION_FEED_REFRESH_MS = 30_000;
export function useNotificationFeed() {
return useQuery({
queryKey: queryKeys.notifications.feed(),
queryFn: notificationsApi.feed,
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
});
}

View file

@ -0,0 +1,31 @@
import {
OPEN_WIZARD_STATUSES,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import type { NotificationTarget } from "@/domain/notifications/types/notification-feed";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
UNASSIGNED_QUEUE_DATE_FROM,
UNASSIGNED_QUEUE_DATE_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */
export function unassignedQueueUrl(): string {
const search = new URLSearchParams();
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return workOrderDrilldownUrl(search);
}
export function notificationTargetUrl(target: NotificationTarget): string | null {
if (target.kind === "queue") {
return target.queue === "unassigned" ? unassignedQueueUrl() : null;
}
if (target.workOrderId == null) {
return null;
}
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
}

View file

@ -0,0 +1,60 @@
import {
NOTIFICATION_REASON_ORDER,
NOTIFICATION_SEVERITY_RANK,
type NotificationItem,
type NotificationSection,
} from "@/domain/notifications/types/notification-feed";
const reasonOrder: ReadonlyMap<string, number> = new Map(
NOTIFICATION_REASON_ORDER.map((reason, index) => [reason, index]),
);
function reasonIndex(reason: string): number {
return reasonOrder.get(reason) ?? NOTIFICATION_REASON_ORDER.length;
}
function highestSeverity(items: NotificationItem[]): NotificationItem["severity"] {
return items.reduce<NotificationItem["severity"]>(
(highest, item) =>
NOTIFICATION_SEVERITY_RANK[item.severity] > NOTIFICATION_SEVERITY_RANK[highest]
? item.severity
: highest,
"Low",
);
}
export function isDismissable(item: NotificationItem): boolean {
return item.rowType === "dismissable";
}
/**
* Applies this session's dismissals to the server feed: dismissed rows leave, section counts drop
* by the work orders those rows represented, empty sections disappear, and sections re-sort by
* their highest remaining severity with the fixed reason order breaking ties.
*/
export function visibleNotificationSections(
sections: NotificationSection[],
dismissedIds: ReadonlySet<string>,
): NotificationSection[] {
return sections
.map((section) => {
const items = section.items.filter(
(item) => !isDismissable(item) || !dismissedIds.has(item.id),
);
const dismissedCount = section.items
.filter((item) => !items.includes(item))
.reduce((total, item) => total + item.count, 0);
return {
...section,
items,
count: Math.max(section.count - dismissedCount, 0),
severity: highestSeverity(items),
};
})
.filter((section) => section.items.length > 0)
.sort(
(left, right) =>
NOTIFICATION_SEVERITY_RANK[right.severity] - NOTIFICATION_SEVERITY_RANK[left.severity] ||
reasonIndex(left.reason) - reasonIndex(right.reason),
);
}

View file

@ -5,7 +5,6 @@ import type {
VendorAvailability,
VendorAssignmentPayload,
VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations";
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
}
export const vendorOperationsApi = {
notifications: async (): Promise<VendorNotification[]> => {
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
`${base}/notifications`,
);
return response.items ?? response.Items ?? [];
},
availability: async (params: {
start?: string;
end?: string;

View file

@ -1,28 +1,3 @@
export interface VendorNotificationAction {
label: string;
href: string;
}
export interface VendorNotification {
id: string;
type: "NoVendor" | "VendorConflict";
severity: "Medium";
group: "No Vendor" | "Vendor Conflict";
title: string;
workOrderId?: number;
workOrderNumber?: string;
triggeredAt: string;
scheduledAt?: string;
action?: VendorNotificationAction;
actions?: VendorNotificationAction[];
workOrders?: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorAvailability {
id: number;
companyName: string;

View file

@ -0,0 +1,48 @@
import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown-links";
const WORK_ORDER_PARAM = "wo";
const TAB_PARAM = "tab";
export const WORK_ORDER_DEEP_LINK_TABS = [
"info",
"comments",
"audit",
"completion",
"extras",
] as const;
export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number];
export type WorkOrderDeepLink = {
workOrderId: string;
tab: WorkOrderDeepLinkTab;
};
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value);
}
/** `/workorders?wo=<id>&tab=<tab>` opens that work order's slide-over on the given tab. */
export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string {
const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) });
if (isDeepLinkTab(tab ?? null)) {
search.set(TAB_PARAM, String(tab));
}
return `${WORK_ORDERS_ROUTE}?${search.toString()}`;
}
export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null {
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
if (!/^\d+$/.test(workOrderId)) {
return null;
}
const tab = params.get(TAB_PARAM);
return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" };
}
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(params);
next.delete(WORK_ORDER_PARAM);
next.delete(TAB_PARAM);
return next;
}

View file

@ -147,6 +147,10 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
sites: () => [...queryKeys.assets.all, "sites"] as const,
},
notifications: {
all: ["notifications"] as const,
feed: () => [...queryKeys.notifications.all, "feed"] as const,
},
vendorPortal: {
all: ["vendorPortal"] as const,
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,

View file

@ -0,0 +1,111 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
const detailState = vi.hoisted(() => ({
data: undefined as unknown,
error: null as Error | null,
requestedIds: [] as (string | number | undefined)[],
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: (id: string | number | undefined) => {
detailState.requestedIds.push(id);
return { data: id == null ? undefined : detailState.data, error: detailState.error };
},
}));
function wrapper(route: string) {
return function Wrapper({ children }: { children: ReactNode }) {
return <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>;
};
}
describe("work order deep link", () => {
beforeEach(() => {
detailState.data = { info: { id: 42, woNumber: "1042" } };
detailState.error = null;
detailState.requestedIds = [];
});
it("opens the linked work order on the requested tab and clears the link", async () => {
const onOpen = vi.fn();
const { result } = renderHook(
() => {
useWorkOrderDeepLink(onOpen);
return useLocation();
},
{ wrapper: wrapper("/workorders?wo=42&tab=extras") },
);
await waitFor(() => expect(result.current.search).toBe(""));
expect(detailState.requestedIds).toContain("42");
expect(onOpen).toHaveBeenCalledTimes(1);
expect(onOpen).toHaveBeenCalledWith({ id: 42, woNumber: "1042" }, false, "extras");
});
it("falls back to the info tab for an unknown tab", async () => {
const onOpen = vi.fn();
renderHook(() => useWorkOrderDeepLink(onOpen), {
wrapper: wrapper("/workorders?wo=42&tab=nope"),
});
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(expect.anything(), false, "info"));
});
it("clears the link without opening anything when the work order cannot load", async () => {
detailState.data = undefined;
detailState.error = new Error("not found");
const onOpen = vi.fn();
const { result } = renderHook(
() => {
useWorkOrderDeepLink(onOpen);
return useLocation();
},
{ wrapper: wrapper("/workorders?wo=42") },
);
await waitFor(() => expect(result.current.search).toBe(""));
expect(onOpen).not.toHaveBeenCalled();
});
it("ignores a board URL without a work order link", () => {
const onOpen = vi.fn();
renderHook(() => useWorkOrderDeepLink(onOpen), { wrapper: wrapper("/workorders?tab=extras") });
expect(detailState.requestedIds.every((id) => id == null)).toBe(true);
expect(onOpen).not.toHaveBeenCalled();
});
});
describe("board drilldown links", () => {
it("applies a second drilldown link while the board stays mounted", async () => {
const onApply = vi.fn();
const { result } = renderHook(
() => {
useDashboardDrilldownFilters(onApply);
return { navigate: useNavigate(), location: useLocation() };
},
{ wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30") },
);
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.location.search).toBe(""));
act(() => {
void result.current.navigate(
"/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned",
);
});
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2));
expect(onApply.mock.calls[1]?.[0]).toMatchObject({
customFrom: "1970-01-01",
customTo: "2099-12-31",
});
});
});

View file

@ -0,0 +1,317 @@
import { 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 NotificationsPage from "@/app/(protected)/notifications";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
import { renderWithProviders } from "@/test/test-utils";
const apiGet = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet }));
type ItemInput = {
id: string;
reason: string;
severity: "Critical" | "High" | "Medium" | "Low";
title: string;
count?: number;
rowType?: "dismissable" | "acknowledge";
target: { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string };
};
function item(input: ItemInput) {
return {
rowType: "dismissable" as ItemInput["rowType"],
count: 1,
triggeredAt: "2026-09-18T12:00:00Z",
workOrders: [],
...input,
};
}
function section(reason: string, label: string, items: ReturnType<typeof item>[]) {
const severity = items[0]?.severity ?? "Low";
return {
reason,
label,
severity,
count: items.reduce((sum, row) => sum + row.count, 0),
items,
};
}
const unassigned = item({
id: "unassigned",
reason: "unassigned",
severity: "High",
title: "3 work orders are unassigned",
count: 3,
target: { kind: "queue", queue: "unassigned" },
});
const avetaFirst = item({
id: "aveta-missing-41",
reason: "aveta-missing",
severity: "Medium",
title: "WO #1041 is missing the Aveta document",
target: { kind: "workOrder", workOrderId: 41, tab: "extras" },
});
const avetaSecond = item({
id: "aveta-missing-42",
reason: "aveta-missing",
severity: "Medium",
title: "WO #1042 is missing the Aveta document",
target: { kind: "workOrder", workOrderId: 42, tab: "extras" },
});
const noVendor = item({
id: "no-vendor-7",
reason: "no-vendor",
severity: "Medium",
title: "WO #1007 starts within 48h with no vendor assigned",
target: { kind: "workOrder", workOrderId: 7, tab: "info" },
});
const acknowledgeRow = item({
id: "sla-9",
reason: "sla",
severity: "Critical",
rowType: "acknowledge",
title: "WO #1009 missed its response deadline",
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
});
function feed(...sections: ReturnType<typeof section>[]) {
return { generatedAt: "2026-09-18T12:00:00Z", sections };
}
const standardFeed = feed(
section("unassigned", "Unassigned", [unassigned]),
section("no-vendor", "No Vendor", [noVendor]),
section("aveta-missing", "Aveta Missing", [avetaFirst, avetaSecond]),
);
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
function renderBell() {
return renderWithProviders(
<NotificationSessionProvider>
<NotificationBell />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false },
);
}
async function openPanel(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: /^Notifications, \d+ unread$/ }));
return screen.findByRole("dialog");
}
function sectionLabels(panel: HTMLElement) {
return within(panel)
.getAllByRole("region")
.map((region) => region.getAttribute("aria-label"));
}
describe("notification center", () => {
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue(standardFeed);
});
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
renderBell();
expect(
await screen.findByRole("button", { name: "Notifications, 4 unread" }),
).toBeInTheDocument();
expect(apiGet).toHaveBeenCalledWith("notifications");
});
it("shows labeled sections with live counts in severity then reason order", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(sectionLabels(panel)).toEqual(["Unassigned, 3", "No Vendor, 1", "Aveta Missing, 2"]);
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
expect(within(aveta).getByRole("heading", { name: "Aveta Missing" })).toBeInTheDocument();
expect(within(aveta).getAllByRole("listitem")).toHaveLength(2);
});
it("never renders an empty section and shows the empty state when nothing is left", async () => {
apiGet.mockResolvedValue(
feed(section("unassigned", "Unassigned", [unassigned]), section("due-soon", "Due Soon", [])),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
await user.click(within(panel).getByRole("button", { name: "Dismiss notification" }));
expect(within(panel).queryAllByRole("region")).toHaveLength(0);
expect(within(panel).getByText("Nothing needs your attention")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
});
it("styles unread rows apart from read ones and marks everything read on request", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
const rows = within(panel).getAllByRole("listitem");
expect(rows.every((row) => row.dataset.unread === "true")).toBe(true);
expect(within(panel).getAllByText("Unread:", { exact: false })).toHaveLength(4);
await user.click(within(panel).getByRole("button", { name: "Mark all read" }));
expect(
within(panel)
.getAllByRole("listitem")
.every((row) => row.dataset.unread === "false"),
).toBe(true);
expect(within(panel).queryByText("Unread:", { exact: false })).not.toBeInTheDocument();
expect(within(panel).getByRole("button", { name: "Mark all read" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
});
it("dismisses one item, lowering its section count, and keeps the dismissal to this session only", async () => {
const user = userEvent.setup();
const { unmount } = renderBell();
const panel = await openPanel(user);
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
const firstRow = within(aveta).getByText(avetaFirst.title).closest("li");
expect(firstRow).not.toBeNull();
await user.click(
within(firstRow as HTMLElement).getByRole("button", { name: "Dismiss notification" }),
);
expect(within(panel).getByRole("region", { name: "Aveta Missing, 1" })).toBeInTheDocument();
expect(within(panel).queryByText(avetaFirst.title)).not.toBeInTheDocument();
expect(within(panel).getByText(avetaSecond.title)).toBeInTheDocument();
unmount();
renderBell();
const freshPanel = await openPanel(user);
expect(within(freshPanel).getByText(avetaFirst.title)).toBeInTheDocument();
});
it("re-sorts sections after a dismissal changes which severity leads", async () => {
apiGet.mockResolvedValue(
feed(
section("aveta-missing", "Aveta Missing", [
item({ ...avetaFirst, severity: "High", title: "WO #1041 urgent" }),
avetaSecond,
]),
section("no-vendor", "No Vendor", [noVendor]),
),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(sectionLabels(panel)).toEqual(["Aveta Missing, 2", "No Vendor, 1"]);
const highRow = within(panel).getByText("WO #1041 urgent").closest("li") as HTMLElement;
await user.click(within(highRow).getByRole("button", { name: "Dismiss notification" }));
expect(sectionLabels(panel)).toEqual(["No Vendor, 1", "Aveta Missing, 1"]);
});
it("clears every dismissable item but keeps rows that need acknowledging", 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;
expect(within(slaRow).queryByRole("button", { name: "Dismiss notification" })).toBeNull();
await user.click(within(panel).getByRole("button", { name: "Clear all" }));
expect(sectionLabels(panel)).toEqual(["SLA at Risk, 1"]);
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();
const panel = await openPanel(user);
await user.click(within(panel).getByRole("button", { name: /3 work orders are unassigned/ }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const location = screen.getByRole("status", { name: "Current location" }).textContent ?? "";
const url = new URL(location, "http://localhost");
expect(url.pathname).toBe("/workorders");
expect(url.searchParams.get("dispatchers")).toBe("__unassigned");
expect(url.searchParams.get("dateFrom")).toBe("1970-01-01");
expect(url.searchParams.get("dateTo")).toBe("2099-12-31");
expect(url.searchParams.get("statuses")).toBeTruthy();
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
});
it("opens an Aveta item on that work order's upload tab", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
await user.click(within(panel).getByRole("button", { name: new RegExp(avetaSecond.title) }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders?wo=42&tab=extras",
);
});
it("serves the same feed as a full page", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
expect(await screen.findByRole("region", { name: "No Vendor, 1" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Notifications" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: new RegExp(noVendor.title) }));
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders?wo=7&tab=info",
);
});
it("tells the user when the feed cannot load", async () => {
apiGet.mockRejectedValue(new Error("boom"));
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
expect(
await screen.findByText("Notifications could not be loaded. They will retry automatically."),
).toBeInTheDocument();
});
});