diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index 67a84215..a078ccf6 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index d95ef194..0db529fe 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index f3a5e057..f4feaee6 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 6487de7d..1b239e83 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index 36602c27..310f2ce5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index 80bcdb86..6742d5c8 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index f724fe2d..bc920e8d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index 8a7a6caf..cffc357d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index 3fcea96e..dba22877 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index ca5acc44..c921fc40 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index 8fb56c7f..73995d8f 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index c046b9c5..c5f9d6e9 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png index 5311388e..1377fbcb 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 35e6b135..121879f8 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 13c7b1eb..57d5671b 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -78,49 +78,54 @@ test.describe("Vendor operations", () => { .toBeGreaterThanOrEqual(280); }); - test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({ + test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({ page, }) => { await authenticate(page); - await page.route("**/api/vendor-operations/notifications", (route) => + const workOrder = { kind: "workOrder", tab: "info" }; + await page.route("**/api/notifications", (route) => fulfillJson(route, { - items: [ + generatedAt: "2026-07-23T12:00:00Z", + sections: [ { - id: "no-vendor-501", - type: "NoVendor", + reason: "no-vendor", + label: "No Vendor", severity: "Medium", - group: "No Vendor", - title: "No vendor assigned — 48h to service", - workOrderId: 501, - workOrderNumber: "WO-501", - triggeredAt: "2026-07-23T12:00:00Z", - scheduledAt: "2026-07-25T12:00:00Z", - action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" }, - }, - { - id: "vendor-conflict-801-802", - type: "VendorConflict", - severity: "Medium", - group: "Vendor Conflict", - title: "Vendor has overlapping appointments", - triggeredAt: "2026-07-23T12:00:00Z", - workOrders: [ + count: 1, + items: [ { - workOrderId: 501, - workOrderNumber: "WO-501", - start: "2026-07-25T12:00:00Z", - end: "2026-07-25T14:00:00Z", - }, - { - workOrderId: 502, - workOrderNumber: "WO-502", - start: "2026-07-25T13:00:00Z", - end: "2026-07-25T15:00:00Z", + id: "no-vendor-501", + reason: "no-vendor", + severity: "Medium", + rowType: "dismissable", + title: "WO #WO-501 starts within 48h with no vendor assigned", + count: 1, + triggeredAt: "2026-07-23T12:00:00Z", + target: { ...workOrder, workOrderId: 501 }, + workOrders: [{ id: 501, number: "WO-501" }], }, ], - actions: [ - { label: "WO-501", href: "/workorders/501" }, - { label: "WO-502", href: "/workorders/502" }, + }, + { + reason: "vendor-conflict", + label: "Vendor Conflict", + severity: "Medium", + count: 2, + items: [ + { + id: "vendor-conflict-501-502", + reason: "vendor-conflict", + severity: "Medium", + rowType: "dismissable", + title: "Acme has overlapping appointments", + count: 2, + triggeredAt: "2026-07-23T12:00:00Z", + target: { ...workOrder, workOrderId: 501 }, + workOrders: [ + { id: 501, number: "WO-501" }, + { id: 502, number: "WO-502" }, + ], + }, ], }, ], @@ -129,21 +134,19 @@ test.describe("Vendor operations", () => { await page.goto("/notifications"); - await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible(); await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute( - "href", - "/workorders/edit/501?assignVendor=1", - ); - await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute( + await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute( "href", "/workorders/501", ); - await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute( + await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute( "href", "/workorders/502", ); + + await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click(); + await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/); }); test("filters insights and exposes both export formats", async ({ page }) => { 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..9ebf9e3e 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 @@ -1,3 +1,4 @@ +import type { Dispatch, SetStateAction } from "react"; import { useEffect, useMemo, useState } from "react"; import type { WorkOrderTablePatch, @@ -19,6 +20,8 @@ function isSlideOverInfoLocked(status: string | null | undefined): boolean { return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status); } +type StateSetter = Dispatch>; + type UseSlideOverEditStateArgs = { row: WorkOrderTableRow | null; editMode: boolean; @@ -27,26 +30,33 @@ type UseSlideOverEditStateArgs = { onClose: () => void; closeDisabled: boolean; setTab: (tab: SlideOverTab) => void; + /** Tab to land on when a work order opens outside edit mode. */ + initialTab?: SlideOverTab; + /** Changes for each explicit open request, including repeated requests for the same row/tab. */ + openRequestKey?: number; }; -export function useSlideOverEditState({ +function useSlideOverInitialization({ row, editMode, - infoSource, - onSave, - onClose, - closeDisabled, + initialTab, setTab, -}: UseSlideOverEditStateArgs) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(null); - /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ - const [baseline, setBaseline] = useState(null); - const [showUnsaved, setShowUnsaved] = useState(false); - const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null); - - // Reset draft/baseline only when the opened WO identity or editMode intent changes — - // not on every optimistic parent-row field update during a failed/in-flight save. + setDraft, + setBaseline, + setEditing, + setShowUnsaved, + setUnsavedIntent, +}: { + row: WorkOrderTableRow | null; + editMode: boolean; + initialTab: SlideOverTab; + setTab: (tab: SlideOverTab) => void; + setDraft: StateSetter; + setBaseline: StateSetter; + setEditing: StateSetter; + setShowUnsaved: StateSetter; + setUnsavedIntent: StateSetter<"close" | "cancel-edit" | null>; +}) { useEffect(() => { if (!row) { setDraft(null); @@ -59,12 +69,50 @@ 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. // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate }, [row?.id, editMode]); +} + +export function useSlideOverEditState({ + row, + editMode, + infoSource, + onSave, + onClose, + closeDisabled, + setTab, + initialTab = "info", + openRequestKey = 0, +}: UseSlideOverEditStateArgs) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(null); + /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ + const [baseline, setBaseline] = useState(null); + const [showUnsaved, setShowUnsaved] = useState(false); + const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null); + + // Reset draft/baseline only when the opened WO identity or editMode intent changes — + // not on every optimistic parent-row field update during a failed/in-flight save. + useSlideOverInitialization({ + row, + editMode, + initialTab, + setTab, + setDraft, + setBaseline, + setEditing, + setShowUnsaved, + setUnsavedIntent, + }); + + useEffect(() => { + if (row?.id == null || editMode) return; + setTab(initialTab); + }, [editMode, initialTab, openRequestKey, row?.id, setTab]); const lockStatus = infoSource?.status ?? row?.status; const isInfoLocked = isSlideOverInfoLocked(lockStatus); @@ -95,8 +143,7 @@ export function useSlideOverEditState({ }); }, [infoSource, editing, baseline]); - const activeDraft = draft ?? infoSource; - const dirtyBaseline = baseline ?? infoSource; + const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource]; const isDirty = useMemo(() => { if (!dirtyBaseline || !activeDraft || !editing) return false; return isSlideOverDraftDirty(activeDraft, dirtyBaseline); 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 1491613d..f9263dd1 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 @@ -34,6 +34,8 @@ type UseWorkOrderSlideOverArgs = { onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onClose: () => void; saving?: boolean; + initialTab?: SlideOverTab; + openRequestKey?: number; }; type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void; @@ -90,6 +92,8 @@ export function useWorkOrderSlideOver({ onSave, onClose, saving, + initialTab, + openRequestKey, }: UseWorkOrderSlideOverArgs) { const { user } = useAuthContext(); const workOrderId = row?.id; @@ -138,6 +142,8 @@ export function useWorkOrderSlideOver({ onClose, closeDisabled, setTab, + initialTab, + openRequestKey, }); 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..545adc0b 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,8 @@ type WorkOrderSlideOverProps = { onDuplicateFound?: (existing: WorkOrderTableRow) => void; onClose: () => void; saving?: boolean; + initialTab?: SlideOverTab; + openRequestKey?: number; }; export function WorkOrderSlideOver({ @@ -52,8 +54,18 @@ export function WorkOrderSlideOver({ onDuplicateFound, onClose, saving, + initialTab, + openRequestKey, }: WorkOrderSlideOverProps) { - const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving }); + const state = useWorkOrderSlideOver({ + row, + editMode, + onSave, + onClose, + saving, + initialTab, + openRequestKey, + }); 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..50972610 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,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps { + 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; + } + if (appliedRef.current) { return; } - const drilldown = parseDashboardDrilldownParams(searchParams); - if (!drilldown) { - return; - } - appliedRef.current = true; - setFromDashboard(true); + setFromDashboard(searchParams.get("fromDashboard") === "1"); onApplyRef.current(drilldown); setSearchParams({}, { replace: true }); }, [searchParams, setSearchParams]); 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..d912d0ec 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, @@ -17,10 +18,45 @@ import { guardDialogClose } from "@/lib/dialog-guard"; import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; import { useWorkOrdersTableData } from "./use-work-orders-table-data"; -export function useWorkOrdersListPage() { - const [wizardOpen, setWizardOpen] = useState(false); +function useSlideOverOpenState() { const [slideOverRow, setSlideOverRow] = useState(null); const [slideOverEdit, setSlideOverEdit] = useState(false); + const [slideOverTab, setSlideOverTab] = useState("info"); + const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0); + + const handleOpenSlideOver = ( + row: WorkOrderTableRow, + edit = false, + tab: SlideOverTab = "info", + ) => { + setSlideOverRow(row); + setSlideOverEdit(edit); + setSlideOverTab(tab); + setSlideOverOpenRequestKey((key) => key + 1); + }; + + return { + slideOverRow, + setSlideOverRow, + slideOverEdit, + setSlideOverEdit, + slideOverTab, + slideOverOpenRequestKey, + handleOpenSlideOver, + }; +} + +export function useWorkOrdersListPage() { + const [wizardOpen, setWizardOpen] = useState(false); + const { + slideOverRow, + setSlideOverRow, + slideOverEdit, + setSlideOverEdit, + slideOverTab, + slideOverOpenRequestKey, + handleOpenSlideOver, + } = useSlideOverOpenState(); const [confirmCancel, setConfirmCancel] = useState(null); const [confirmComplete, setConfirmComplete] = useState(null); const [docRow, setDocRow] = useState(null); @@ -74,11 +110,6 @@ export function useWorkOrdersListPage() { return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow; }, [upliftsRow, tableData.rows]); - const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { - setSlideOverRow(row); - setSlideOverEdit(edit); - }; - const handleCloseSlideOver = () => { setSlideOverRow(null); setSlideOverEdit(false); @@ -130,6 +161,8 @@ export function useWorkOrdersListPage() { wizardOpen, setWizardOpen, slideOverEdit, + slideOverTab, + slideOverOpenRequestKey, 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-user-menu.tsx b/src/components/layout/app-topbar-user-menu.tsx new file mode 100644 index 00000000..98325fff --- /dev/null +++ b/src/components/layout/app-topbar-user-menu.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; +import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; +import { + Box, + Divider, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Typography, +} from "@mui/material"; +import { ChevronDown } from "lucide-react"; + +import { useAuthContext } from "@/providers/auth-context"; +import { getPrimaryUserRole } from "@/lib/auth/user-utils"; + +type UserMenuHeaderProps = { + initials: string; + fullname: string; + email: string; +}; + +function UserMenuHeader({ initials, fullname, email }: UserMenuHeaderProps) { + return ( + + + {initials} + + + + {fullname} + + + {email} + + + + ); +} + +export function AppTopbarUserMenu() { + const navigate = useNavigate(); + const { user, logout } = useAuthContext(); + + const [userAnchor, setUserAnchor] = useState(null); + + const handleLogout = () => { + setUserAnchor(null); + logout(); + }; + + const userInitials = user?.fullname + ? user.fullname + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase() + : "U"; + + const userRole = getPrimaryUserRole(user?.userRoles); + + return ( + <> + setUserAnchor(event.currentTarget)} + sx={{ + display: "flex", + alignItems: "center", + flexShrink: 0, + gap: "10px", + py: "5px", + pl: "6px", + pr: "10px", + borderRadius: "9999px", + border: "none", + cursor: "pointer", + backgroundColor: "transparent", + color: "var(--color-header-text)", + transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)", + "&:hover": { + backgroundColor: "var(--color-header-chip-hover)", + }, + }} + > + + {userInitials} + + + + {user?.fullname || "User"} + + + {userRole} + + + + + + setUserAnchor(null)} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + slotProps={{ + paper: { + sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" }, + }, + }} + > + + + { + setUserAnchor(null); + navigate("/settings/dropdowns"); + }} + > + + + + Settings + + + + + + Logout + + + + ); +} diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx index b2a5b417..1be6b892 100644 --- a/src/components/layout/app-topbar.tsx +++ b/src/components/layout/app-topbar.tsx @@ -1,49 +1,14 @@ -import { useState } from "react"; -import { useNavigate } from "react-router"; -import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; -import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; -import { - AppBar, - Box, - Divider, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Toolbar, - Typography, -} from "@mui/material"; -import { ChevronDown, Menu as MenuIcon } from "lucide-react"; +import { AppBar, Box, Toolbar } from "@mui/material"; +import { Menu as MenuIcon } from "lucide-react"; -import { useAuthContext } from "@/providers/auth-context"; -import { getPrimaryUserRole } from "@/lib/auth/user-utils"; +import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu"; +import { NotificationBell } from "@/components/notifications/notification-bell"; type AppTopbarProps = { onToggleNav: () => void; }; export function AppTopbar({ onToggleNav }: AppTopbarProps) { - const navigate = useNavigate(); - const { user, logout } = useAuthContext(); - - const [userAnchor, setUserAnchor] = useState(null); - - const handleLogout = () => { - setUserAnchor(null); - logout(); - }; - - const userInitials = user?.fullname - ? user.fullname - .split(" ") - .map((part) => part[0]) - .join("") - .slice(0, 2) - .toUpperCase() - : "U"; - - const userRole = getPrimaryUserRole(user?.userRoles); - return ( - setUserAnchor(event.currentTarget)} - sx={{ - display: "flex", - alignItems: "center", - flexShrink: 0, - gap: "10px", - py: "5px", - pl: "6px", - pr: "10px", - borderRadius: "9999px", - border: "none", - cursor: "pointer", - backgroundColor: "transparent", - color: "var(--color-header-text)", - transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)", - "&:hover": { - backgroundColor: "var(--color-header-chip-hover)", - }, - }} - > - - {userInitials} - - - - {user?.fullname || "User"} - - - {userRole} - - - - - + - setUserAnchor(null)} - transformOrigin={{ horizontal: "right", vertical: "top" }} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - slotProps={{ - paper: { - sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" }, - }, - }} - > - - - {userInitials} - - - - {user?.fullname || "User"} - - - {user?.email || ""} - - - - - { - setUserAnchor(null); - navigate("/settings/dropdowns"); - }} - > - - - - Settings - - - - - - Logout - - + + ); } diff --git a/src/components/notifications/notification-bell.tsx b/src/components/notifications/notification-bell.tsx new file mode 100644 index 00000000..ed779a54 --- /dev/null +++ b/src/components/notifications/notification-bell.tsx @@ -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 ( + <> + 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..48215231 --- /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 && !center.hasData) { + 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..a081ca1b --- /dev/null +++ b/src/components/notifications/notification-row.tsx @@ -0,0 +1,127 @@ +import type { LucideIcon } from "lucide-react"; +import { + AlertTriangle, + Bell, + Clock, + Inbox, + MessageSquare, + ShieldAlert, + Wrench, + X, +} from "lucide-react"; +import { Link } from "react-router"; +import { Text } from "@/components/ui/text"; +import type { NotificationItem } from "@/domain/notifications/types/notification-feed"; +import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url"; +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, url?: string) => void; + onDismiss: (item: NotificationItem) => void; +}; + +/** A vendor conflict spans several work orders, so each one stays reachable from the row. */ +function WorkOrderLinks({ item, onOpen }: Pick) { + if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) { + return null; + } + + return ( + + {item.workOrders.map((workOrder) => { + const url = workOrderViewUrl(workOrder.id); + return ( + { + event.preventDefault(); + onOpen(item, url); + }} + className="text-xs underline-offset-2 hover:underline" + style={{ color: "var(--color-primary)" }} + > + {workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`} + + ); + })} + + ); +} + +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..14e8e990 --- /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, url = notificationTargetUrl(item)) => { + markRead([item.id]); + if (url == null) return; + onNavigate?.(); + navigate(url); + }; + + return { + sections, + unreadIds, + isLoading: feed.isLoading, + hasData: feed.data != null, + 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/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts index c83d0592..a89fc69c 100644 --- a/src/domain/dashboard/utils/work-order-drilldown-links.ts +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte (status) => status !== "Completed", ); -export function workOrderDrilldownUrl(search: URLSearchParams): string { - const query = search.toString(); +export function workOrderDrilldownUrl( + search: URLSearchParams, + source: "dashboard" | "notification" = "dashboard", +): string { + const params = new URLSearchParams(search); + if (source === "dashboard") { + params.set("fromDashboard", "1"); + } + const query = params.toString(); return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE; } 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..98e9786b --- /dev/null +++ b/src/domain/notifications/use-cases/use-notification-feed.ts @@ -0,0 +1,16 @@ +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, + // The feed shows its own error state; a toast on every 30s poll would repeat the same failure. + meta: { suppressErrorToast: true }, + }); +} 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..10ac1867 --- /dev/null +++ b/src/domain/notifications/utils/notification-target-url.ts @@ -0,0 +1,46 @@ +import { + OPEN_WIZARD_STATUSES, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import type { NotificationItem } 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, "notification"); +} + +/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */ +export function chooseVendorUrl(workOrderId: number): string { + return `/workorders/edit/${workOrderId}?assignVendor=1`; +} + +export function workOrderViewUrl(workOrderId: number): string { + return `/workorders/${workOrderId}`; +} + +export function notificationTargetUrl( + item: Pick, +): string | null { + const { target } = item; + if (target.kind === "queue") { + return target.queue === "unassigned" ? unassignedQueueUrl() : null; + } + if (target.workOrderId == null) { + return null; + } + if (item.reason === "no-vendor") { + return chooseVendorUrl(target.workOrderId); + } + 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 d5e49f9b..eb8a0664 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -149,6 +149,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)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index 5ad7396e..37c3632e 100644 --- a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -80,6 +80,7 @@ import DashboardPage from "@/app/(protected)/dashboard"; import { avetaPendingDrilldownSearch, scheduledTomorrowDrilldownSearch, + workOrderDrilldownUrl, } from "@/domain/dashboard/utils/work-order-drilldown-links"; import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils"; @@ -161,7 +162,7 @@ describe("DashboardPage", () => { const today = businessTodayIso(); expect(navigate).toHaveBeenCalledWith( - `/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`, + workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)), ); fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ })); diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx index 1245a22f..b6882d4d 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "react-toastify"; import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state"; +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 { vendorAssignedMessage, @@ -111,6 +112,34 @@ describe("useSlideOverEditState completed lock", () => { expect(onSave).not.toHaveBeenCalled(); }); + it("applies a repeated same-row tab request without resetting the draft", () => { + const open = baseRow(); + const setTab = vi.fn(); + const { result, rerender } = renderHook( + ({ initialTab, openRequestKey }) => + useSlideOverEditState({ + row: open, + editMode: false, + infoSource: open, + onSave: vi.fn(), + onClose: vi.fn(), + closeDisabled: false, + setTab, + initialTab, + openRequestKey, + }), + { initialProps: { initialTab: "info" as SlideOverTab, openRequestKey: 1 } }, + ); + + act(() => { + result.current.handleDraftChange({ woNumber: "edited" }); + }); + rerender({ initialTab: "extras", openRequestKey: 2 }); + + expect(setTab).toHaveBeenLastCalledWith("extras"); + expect(result.current.activeDraft?.woNumber).toBe("edited"); + }); + it("toasts vendor assigned after a successful save that includes vendorId", () => { const onSave = vi.fn(); const open = baseRow(); 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..64da8dd0 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-order-deep-link.test.tsx @@ -0,0 +1,137 @@ +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", + }); + }); + + it("keeps dashboard provenance separate from notification drilldowns", async () => { + const onApply = vi.fn(); + const { result } = renderHook( + () => { + const fromDashboard = useDashboardDrilldownFilters(onApply); + return { fromDashboard, navigate: useNavigate(), location: useLocation() }; + }, + { + wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30&fromDashboard=1"), + }, + ); + + await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.location.search).toBe("")); + expect(result.current.fromDashboard).toBe(true); + + act(() => { + void result.current.navigate( + "/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned", + ); + }); + + await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.fromDashboard).toBe(false)); + }); +}); 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..388a3661 --- /dev/null +++ b/src/test/components/notifications/notification-center.test.tsx @@ -0,0 +1,362 @@ +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 }; + workOrders?: { id: number; number: string }[]; +}; + +function item(input: ItemInput) { + return { + rowType: "dismissable" as ItemInput["rowType"], + count: 1, + triggeredAt: "2026-09-18T12:00:00Z", + workOrders: [] as { id: number; number: string }[], + ...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(url.searchParams.get("fromDashboard")).toBeNull(); + 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 and sends No Vendor straight to choosing a vendor", 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/edit/7?assignVendor=1", + ); + }); + + it("links every work order in a vendor conflict", async () => { + apiGet.mockResolvedValue( + feed( + section("vendor-conflict", "Vendor Conflict", [ + item({ + id: "vendor-conflict-501-502", + reason: "vendor-conflict", + severity: "Medium", + title: "Acme Plumbing has overlapping appointments", + count: 2, + target: { kind: "workOrder", workOrderId: 501, tab: "info" }, + workOrders: [ + { id: 501, number: "501" }, + { id: 502, number: "502" }, + ], + }), + ]), + ), + ); + const user = userEvent.setup(); + renderWithProviders( + + + + , + { withAuth: false, route: "/notifications" }, + ); + + const conflicts = await screen.findByRole("region", { name: "Vendor Conflict, 2" }); + expect(within(conflicts).getByRole("link", { name: "WO #501" })).toHaveAttribute( + "href", + "/workorders/501", + ); + + await user.click(within(conflicts).getByRole("link", { name: "WO #502" })); + + expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent( + "/workorders/502", + ); + const row = within(conflicts).getByRole("listitem"); + expect(row.dataset.unread).toBe("false"); + }); + + 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(); + }); +}); diff --git a/src/test/domain/dashboard/work-order-drilldown-links.test.ts b/src/test/domain/dashboard/work-order-drilldown-links.test.ts index 836da9e6..97be821c 100644 --- a/src/test/domain/dashboard/work-order-drilldown-links.test.ts +++ b/src/test/domain/dashboard/work-order-drilldown-links.test.ts @@ -51,9 +51,12 @@ describe("work-order drilldown links", () => { }); it("renders plain or query-bearing work order URLs", () => { - expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders"); + expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders?fromDashboard=1"); expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe( - "/workorders?statuses=Scheduled", + "/workorders?statuses=Scheduled&fromDashboard=1", ); + expect( + workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }), "notification"), + ).toBe("/workorders?statuses=Scheduled"); }); });