From dcf944d7dcf7e22daab446b9f2fceff661f6fbdf Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Fri, 18 Sep 2026 12:55:54 -0300 Subject: [PATCH] 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=. --- src/app/(protected)/_layout.tsx | 39 ++- src/app/(protected)/notifications/index.tsx | 114 +------ .../detail/use-slide-over-edit-state.ts | 5 +- .../detail/use-work-order-slide-over.ts | 3 + .../detail/work-order-slide-over.tsx | 6 +- .../list/work-orders-list-page-panels.tsx | 1 + .../_hooks/use-dashboard-drilldown-filters.ts | 8 +- .../_hooks/use-work-order-deep-link.ts | 42 +++ .../_hooks/use-work-orders-list-page.ts | 10 +- src/app/(protected)/workorders/index.tsx | 2 + src/components/layout/app-topbar.tsx | 3 + .../notifications/notification-bell.tsx | 98 ++++++ .../notification-feed-actions.tsx | 33 ++ .../notifications/notification-feed-list.tsx | 89 +++++ .../notifications/notification-row.tsx | 93 +++++ .../notifications/use-notification-center.ts | 47 +++ .../notifications/api/notifications-api.ts | 10 + .../session/notification-session-context.ts | 18 + .../session/notification-session-provider.tsx | 38 +++ .../notifications/types/notification-feed.ts | 62 ++++ .../use-cases/use-notification-feed.ts | 14 + .../utils/notification-target-url.ts | 31 ++ .../utils/visible-notification-sections.ts | 60 ++++ .../api/vendor-operations-api.ts | 8 - .../types/vendor-operations.ts | 25 -- .../work-orders/utils/work-order-deep-link.ts | 48 +++ src/infra/query-key/query-key.ts | 4 + .../use-work-order-deep-link.test.tsx | 111 ++++++ .../notification-center.test.tsx | 317 ++++++++++++++++++ 29 files changed, 1185 insertions(+), 154 deletions(-) create mode 100644 src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts create mode 100644 src/components/notifications/notification-bell.tsx create mode 100644 src/components/notifications/notification-feed-actions.tsx create mode 100644 src/components/notifications/notification-feed-list.tsx create mode 100644 src/components/notifications/notification-row.tsx create mode 100644 src/components/notifications/use-notification-center.ts create mode 100644 src/domain/notifications/api/notifications-api.ts create mode 100644 src/domain/notifications/session/notification-session-context.ts create mode 100644 src/domain/notifications/session/notification-session-provider.tsx create mode 100644 src/domain/notifications/types/notification-feed.ts create mode 100644 src/domain/notifications/use-cases/use-notification-feed.ts create mode 100644 src/domain/notifications/utils/notification-target-url.ts create mode 100644 src/domain/notifications/utils/visible-notification-sections.ts create mode 100644 src/domain/work-orders/utils/work-order-deep-link.ts create mode 100644 src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx create mode 100644 src/test/components/notifications/notification-center.test.tsx diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index eca5363c..a5826b19 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; +import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; export default function AdminShellLayout() { const theme = useTheme(); @@ -25,29 +26,31 @@ export default function AdminShellLayout() { return ( - - setMobileNavOpen(false)} - /> - - - + + + setMobileNavOpen(false)} + /> - + + + + + - + ); } diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx index c4e0b75e..a361fc38 100644 --- a/src/app/(protected)/notifications/index.tsx +++ b/src/app/(protected)/notifications/index.tsx @@ -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()); + const center = useNotificationCenter(); return ( - - - - Items requiring attention - - - Medium-severity vendor reminders refresh automatically. They do not create banners or - toasts. - + + + + + } + /> + + - {isLoading && } - {Boolean(error) && {error?.message}} - {!isLoading && !error && data.length === 0 && ( - - No vendor alerts require attention. - - )} - {[...groups.entries()].map(([group, items]) => ( - - - {group} - - - - {items.map((item) => ( - - - - - - {item.title} - - - {item.workOrderNumber ?? - item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")} - - {item.scheduledAt != null && ( - - Scheduled {new Date(item.scheduledAt).toLocaleString()} - - )} - - - {item.action != null && ( - - )} - {item.actions?.map((action) => ( - - ))} - - - - ))} - - - ))} ); } diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 4fe4a6b1..e0c0e434 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -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(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. diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index dee476ec..7d3e83b1 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -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) => { diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index c240c19e..00985a1b 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -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 ( diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index 8b150532..7be55394 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -51,6 +51,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps { - 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; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts b/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts new file mode 100644 index 00000000..335906f3 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-order-deep-link.ts @@ -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=&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]); +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 182a98e5..e4cb3ada 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -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(null); const [slideOverEdit, setSlideOverEdit] = useState(false); + const [slideOverTab, setSlideOverTab] = useState("info"); const [confirmCancel, setConfirmCancel] = useState(null); const [confirmComplete, setConfirmComplete] = useState(null); const [docRow, setDocRow] = useState(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, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 23fe3074..5e32808e 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -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 ( diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx index b2a5b417..9dd16781 100644 --- a/src/components/layout/app-topbar.tsx +++ b/src/components/layout/app-topbar.tsx @@ -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) { + + setOpen(false)); + const unreadCount = center.unreadIds.size; + + return ( + <> + 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)" }, + }} + > + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + setOpen(false)} + slotProps={{ paper: { sx: { width: 420, background: "var(--color-bg-surface)" } } }} + > +
+ + Notifications + + + +
+
+ +
+
+ + ); +} diff --git a/src/components/notifications/notification-feed-actions.tsx b/src/components/notifications/notification-feed-actions.tsx new file mode 100644 index 00000000..3df626e2 --- /dev/null +++ b/src/components/notifications/notification-feed-actions.tsx @@ -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 ( + <> + + + + ); +} diff --git a/src/components/notifications/notification-feed-list.tsx b/src/components/notifications/notification-feed-list.tsx new file mode 100644 index 00000000..1a363525 --- /dev/null +++ b/src/components/notifications/notification-feed-list.tsx @@ -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 ( +
+ +
+ ); + } + + if (center.error != null) { + return ( + + Notifications could not be loaded. They will retry automatically. + + ); + } + + if (center.sections.length === 0) { + return ( +
+ + + Nothing needs your attention + +
+ ); + } + + return ( +
+ {center.sections.map((section) => ( +
+
+ + {section.label} + + + {section.count} + +
+
    + {section.items.map((item) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx new file mode 100644 index 00000000..024a86b0 --- /dev/null +++ b/src/components/notifications/notification-row.tsx @@ -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 = { + "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 ( +
  • + + {isDismissable(item) && ( + + )} +
  • + ); +} diff --git a/src/components/notifications/use-notification-center.ts b/src/components/notifications/use-notification-center.ts new file mode 100644 index 00000000..5eac89cc --- /dev/null +++ b/src/components/notifications/use-notification-center.ts @@ -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; diff --git a/src/domain/notifications/api/notifications-api.ts b/src/domain/notifications/api/notifications-api.ts new file mode 100644 index 00000000..034556f2 --- /dev/null +++ b/src/domain/notifications/api/notifications-api.ts @@ -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 => + notificationFeedSchema.parse(await apiGet("notifications")), +}; diff --git a/src/domain/notifications/session/notification-session-context.ts b/src/domain/notifications/session/notification-session-context.ts new file mode 100644 index 00000000..c5697e97 --- /dev/null +++ b/src/domain/notifications/session/notification-session-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +export type NotificationSession = { + dismissedIds: ReadonlySet; + readIds: ReadonlySet; + dismiss: (ids: string[]) => void; + markRead: (ids: string[]) => void; +}; + +export const NotificationSessionContext = createContext(null); + +export function useNotificationSession(): NotificationSession { + const session = useContext(NotificationSessionContext); + if (!session) { + throw new Error("useNotificationSession must be used within a NotificationSessionProvider"); + } + return session; +} diff --git a/src/domain/notifications/session/notification-session-provider.tsx b/src/domain/notifications/session/notification-session-provider.tsx new file mode 100644 index 00000000..87bae800 --- /dev/null +++ b/src/domain/notifications/session/notification-session-provider.tsx @@ -0,0 +1,38 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { NotificationSessionContext } from "./notification-session-context"; + +function addAll(current: ReadonlySet, ids: string[]): ReadonlySet { + 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>(() => new Set()); + const [readIds, setReadIds] = useState>(() => 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 ( + + {children} + + ); +} diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts new file mode 100644 index 00000000..fc5e7bdf --- /dev/null +++ b/src/domain/notifications/types/notification-feed.ts @@ -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; +export type NotificationTarget = z.infer; +export type NotificationItem = z.infer; +export type NotificationSection = z.infer; +export type NotificationFeed = z.infer; diff --git a/src/domain/notifications/use-cases/use-notification-feed.ts b/src/domain/notifications/use-cases/use-notification-feed.ts new file mode 100644 index 00000000..54fb34e5 --- /dev/null +++ b/src/domain/notifications/use-cases/use-notification-feed.ts @@ -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, + }); +} diff --git a/src/domain/notifications/utils/notification-target-url.ts b/src/domain/notifications/utils/notification-target-url.ts new file mode 100644 index 00000000..a60ffd98 --- /dev/null +++ b/src/domain/notifications/utils/notification-target-url.ts @@ -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); +} diff --git a/src/domain/notifications/utils/visible-notification-sections.ts b/src/domain/notifications/utils/visible-notification-sections.ts new file mode 100644 index 00000000..5e680889 --- /dev/null +++ b/src/domain/notifications/utils/visible-notification-sections.ts @@ -0,0 +1,60 @@ +import { + NOTIFICATION_REASON_ORDER, + NOTIFICATION_SEVERITY_RANK, + type NotificationItem, + type NotificationSection, +} from "@/domain/notifications/types/notification-feed"; + +const reasonOrder: ReadonlyMap = 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( + (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, +): 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), + ); +} diff --git a/src/domain/vendor-operations/api/vendor-operations-api.ts b/src/domain/vendor-operations/api/vendor-operations-api.ts index 9c982840..47dcc8c2 100644 --- a/src/domain/vendor-operations/api/vendor-operations-api.ts +++ b/src/domain/vendor-operations/api/vendor-operations-api.ts @@ -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 => { - const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>( - `${base}/notifications`, - ); - return response.items ?? response.Items ?? []; - }, - availability: async (params: { start?: string; end?: string; diff --git a/src/domain/vendor-operations/types/vendor-operations.ts b/src/domain/vendor-operations/types/vendor-operations.ts index 246178da..0b29921b 100644 --- a/src/domain/vendor-operations/types/vendor-operations.ts +++ b/src/domain/vendor-operations/types/vendor-operations.ts @@ -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; diff --git a/src/domain/work-orders/utils/work-order-deep-link.ts b/src/domain/work-orders/utils/work-order-deep-link.ts new file mode 100644 index 00000000..ec39db1b --- /dev/null +++ b/src/domain/work-orders/utils/work-order-deep-link.ts @@ -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=&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; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index d5234338..2605546d 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -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, diff --git a/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx new file mode 100644 index 00000000..3d12db23 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx @@ -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 {children}; + }; +} + +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", + }); + }); +}); diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx new file mode 100644 index 00000000..d00c4342 --- /dev/null +++ b/src/test/components/notifications/notification-center.test.tsx @@ -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[]) { + 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[]) { + 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 {`${location.pathname}${location.search}`}; +} + +function renderBell() { + return renderWithProviders( + + + + , + { withAuth: false }, + ); +} + +async function openPanel(user: ReturnType) { + 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( + + + + , + { 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( + + + , + { withAuth: false, route: "/notifications" }, + ); + + expect( + await screen.findByText("Notifications could not be loaded. They will retry automatically."), + ).toBeInTheDocument(); + }); +});