mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
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:
parent
d6510b262e
commit
dcf944d7dc
29 changed files with 1185 additions and 154 deletions
|
|
@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
import { getSidebarWidth } from "@/config/menu";
|
import { getSidebarWidth } from "@/config/menu";
|
||||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||||
|
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||||
|
|
||||||
export default function AdminShellLayout() {
|
export default function AdminShellLayout() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
<NotificationSessionProvider>
|
||||||
<AppSidebar
|
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||||
collapsed={isDesktop ? navCollapsed : false}
|
<AppSidebar
|
||||||
mobile={!isDesktop}
|
collapsed={isDesktop ? navCollapsed : false}
|
||||||
open={isDesktop || mobileNavOpen}
|
mobile={!isDesktop}
|
||||||
onClose={() => setMobileNavOpen(false)}
|
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} />
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
className="flex-1 overflow-auto"
|
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||||
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
|
||||||
>
|
>
|
||||||
<Outlet />
|
<AppTopbar onToggleNav={handleToggleNav} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
className="flex-1 overflow-auto"
|
||||||
|
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</NotificationSessionProvider>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,26 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { Box } from "@mui/material";
|
||||||
import {
|
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
|
||||||
Alert,
|
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
|
||||||
Box,
|
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
|
||||||
Button,
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Link } from "react-router";
|
|
||||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const {
|
const center = useNotificationCenter();
|
||||||
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>());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-2 p-2.5">
|
||||||
<Box>
|
<PageHeader
|
||||||
<Typography variant="h5" component="h1">
|
surface
|
||||||
Items requiring attention
|
title="Notifications"
|
||||||
</Typography>
|
actions={
|
||||||
<Typography color="text.secondary">
|
<div className="flex items-center gap-3">
|
||||||
Medium-severity vendor reminders refresh automatically. They do not create banners or
|
<NotificationFeedActions center={center} />
|
||||||
toasts.
|
</div>
|
||||||
</Typography>
|
}
|
||||||
|
/>
|
||||||
|
<Box className="max-w-3xl px-5 pb-5">
|
||||||
|
<NotificationFeedList center={center} />
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ type UseSlideOverEditStateArgs = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
closeDisabled: boolean;
|
closeDisabled: boolean;
|
||||||
setTab: (tab: SlideOverTab) => void;
|
setTab: (tab: SlideOverTab) => void;
|
||||||
|
/** Tab to land on when a work order opens outside edit mode. */
|
||||||
|
initialTab?: SlideOverTab;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSlideOverEditState({
|
export function useSlideOverEditState({
|
||||||
|
|
@ -37,6 +39,7 @@ export function useSlideOverEditState({
|
||||||
onClose,
|
onClose,
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
setTab,
|
setTab,
|
||||||
|
initialTab = "info",
|
||||||
}: UseSlideOverEditStateArgs) {
|
}: UseSlideOverEditStateArgs) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
|
@ -59,7 +62,7 @@ export function useSlideOverEditState({
|
||||||
setDraft(row);
|
setDraft(row);
|
||||||
setBaseline(row);
|
setBaseline(row);
|
||||||
setEditing(editMode && !isSlideOverInfoLocked(row.status));
|
setEditing(editMode && !isSlideOverInfoLocked(row.status));
|
||||||
setTab("info");
|
setTab(editMode ? "info" : initialTab);
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
setUnsavedIntent(null);
|
setUnsavedIntent(null);
|
||||||
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type UseWorkOrderSlideOverArgs = {
|
||||||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
initialTab?: SlideOverTab;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CompletionUploadMutate = (
|
type CompletionUploadMutate = (
|
||||||
|
|
@ -112,6 +113,7 @@ export function useWorkOrderSlideOver({
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
|
initialTab,
|
||||||
}: UseWorkOrderSlideOverArgs) {
|
}: UseWorkOrderSlideOverArgs) {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const workOrderId = row?.id;
|
const workOrderId = row?.id;
|
||||||
|
|
@ -160,6 +162,7 @@ export function useWorkOrderSlideOver({
|
||||||
onClose,
|
onClose,
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
setTab,
|
setTab,
|
||||||
|
initialTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadCompletionPdf = (file: File) => {
|
const uploadCompletionPdf = (file: File) => {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} 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 { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
||||||
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||||
|
|
||||||
|
|
@ -35,6 +35,7 @@ type WorkOrderSlideOverProps = {
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
initialTab?: SlideOverTab;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderSlideOver({
|
export function WorkOrderSlideOver({
|
||||||
|
|
@ -52,8 +53,9 @@ export function WorkOrderSlideOver({
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
|
initialTab,
|
||||||
}: WorkOrderSlideOverProps) {
|
}: WorkOrderSlideOverProps) {
|
||||||
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
|
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving, initialTab });
|
||||||
const { infoSource, activeDraft } = state;
|
const { infoSource, activeDraft } = state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
||||||
<WorkOrderSlideOver
|
<WorkOrderSlideOver
|
||||||
row={page.activeSlideOverRow}
|
row={page.activeSlideOverRow}
|
||||||
editMode={page.slideOverEdit}
|
editMode={page.slideOverEdit}
|
||||||
|
initialTab={page.slideOverTab}
|
||||||
users={users}
|
users={users}
|
||||||
sites={locations}
|
sites={locations}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,14 @@ export function useDashboardDrilldownFilters(
|
||||||
}, [onApply]);
|
}, [onApply]);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const drilldown = parseDashboardDrilldownParams(searchParams);
|
if (appliedRef.current) {
|
||||||
if (!drilldown) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
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 { 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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
import {
|
||||||
useWorkOrderLocations,
|
useWorkOrderLocations,
|
||||||
|
|
@ -21,6 +22,7 @@ export function useWorkOrdersListPage() {
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||||
|
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
|
||||||
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [docRow, setDocRow] = 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;
|
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
|
||||||
}, [upliftsRow, tableData.rows]);
|
}, [upliftsRow, tableData.rows]);
|
||||||
|
|
||||||
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
const handleOpenSlideOver = (
|
||||||
|
row: WorkOrderTableRow,
|
||||||
|
edit = false,
|
||||||
|
tab: SlideOverTab = "info",
|
||||||
|
) => {
|
||||||
setSlideOverRow(row);
|
setSlideOverRow(row);
|
||||||
setSlideOverEdit(edit);
|
setSlideOverEdit(edit);
|
||||||
|
setSlideOverTab(tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseSlideOver = () => {
|
const handleCloseSlideOver = () => {
|
||||||
|
|
@ -130,6 +137,7 @@ export function useWorkOrdersListPage() {
|
||||||
wizardOpen,
|
wizardOpen,
|
||||||
setWizardOpen,
|
setWizardOpen,
|
||||||
slideOverEdit,
|
slideOverEdit,
|
||||||
|
slideOverTab,
|
||||||
confirmCancel,
|
confirmCancel,
|
||||||
setConfirmCancel,
|
setConfirmCancel,
|
||||||
confirmComplete,
|
confirmComplete,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
|
||||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
|
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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
|
||||||
const page = useWorkOrdersListPage();
|
const page = useWorkOrdersListPage();
|
||||||
const { filters, tableData } = page;
|
const { filters, tableData } = page;
|
||||||
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||||
|
useWorkOrderDeepLink(page.handleOpenSlideOver);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
|
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
|
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
|
||||||
|
|
||||||
|
|
@ -97,6 +98,8 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
||||||
|
|
||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<NotificationBell />
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
98
src/components/notifications/notification-bell.tsx
Normal file
98
src/components/notifications/notification-bell.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/notifications/notification-feed-actions.tsx
Normal file
33
src/components/notifications/notification-feed-actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/notifications/notification-feed-list.tsx
Normal file
89
src/components/notifications/notification-feed-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/notifications/notification-row.tsx
Normal file
93
src/components/notifications/notification-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/notifications/use-notification-center.ts
Normal file
47
src/components/notifications/use-notification-center.ts
Normal 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>;
|
||||||
10
src/domain/notifications/api/notifications-api.ts
Normal file
10
src/domain/notifications/api/notifications-api.ts
Normal 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")),
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/domain/notifications/types/notification-feed.ts
Normal file
62
src/domain/notifications/types/notification-feed.ts
Normal 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>;
|
||||||
14
src/domain/notifications/use-cases/use-notification-feed.ts
Normal file
14
src/domain/notifications/use-cases/use-notification-feed.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
src/domain/notifications/utils/notification-target-url.ts
Normal file
31
src/domain/notifications/utils/notification-target-url.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ import type {
|
||||||
VendorAvailability,
|
VendorAvailability,
|
||||||
VendorAssignmentPayload,
|
VendorAssignmentPayload,
|
||||||
VendorInsight,
|
VendorInsight,
|
||||||
VendorNotification,
|
|
||||||
} from "@/domain/vendor-operations/types/vendor-operations";
|
} from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
|
||||||
const base = "vendor-operations";
|
const base = "vendor-operations";
|
||||||
|
|
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vendorOperationsApi = {
|
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: {
|
availability: async (params: {
|
||||||
start?: string;
|
start?: string;
|
||||||
end?: string;
|
end?: string;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export interface VendorAvailability {
|
||||||
id: number;
|
id: number;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
|
||||||
48
src/domain/work-orders/utils/work-order-deep-link.ts
Normal file
48
src/domain/work-orders/utils/work-order-deep-link.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,10 @@ export const queryKeys = {
|
||||||
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
|
||||||
sites: () => [...queryKeys.assets.all, "sites"] as const,
|
sites: () => [...queryKeys.assets.all, "sites"] as const,
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
all: ["notifications"] as const,
|
||||||
|
feed: () => [...queryKeys.notifications.all, "feed"] as const,
|
||||||
|
},
|
||||||
vendorPortal: {
|
vendorPortal: {
|
||||||
all: ["vendorPortal"] as const,
|
all: ["vendorPortal"] as const,
|
||||||
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,
|
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
317
src/test/components/notifications/notification-center.test.tsx
Normal file
317
src/test/components/notifications/notification-center.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue