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 d3fb7c1f..0304111b 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 @@ -29,8 +29,8 @@ type UseSlideOverEditStateArgs = { setTab: (tab: SlideOverTab) => void; /** Tab to land on when a work order opens outside edit mode. */ initialTab?: SlideOverTab; - /** Increments on every open so re-opening the same work order re-applies the requested tab. */ - openSeq?: number; + /** Changes for each explicit open request, including repeated requests for the same row/tab. */ + openRequestKey?: number; }; export function useSlideOverEditState({ @@ -42,7 +42,7 @@ export function useSlideOverEditState({ closeDisabled, setTab, initialTab = "info", - openSeq, + openRequestKey = 0, }: UseSlideOverEditStateArgs) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); @@ -78,8 +78,8 @@ export function useSlideOverEditState({ useEffect(() => { if (!row) return; setTab(editMode ? "info" : initialTab); - // eslint-disable-next-line react-hooks/exhaustive-deps -- openSeq gates each open - }, [openSeq]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open + }, [openRequestKey]); const lockStatus = infoSource?.status ?? row?.status; const isInfoLocked = isSlideOverInfoLocked(lockStatus); 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 341e163e..04d78923 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 @@ -36,7 +36,7 @@ type UseWorkOrderSlideOverArgs = { onClose: () => void; saving?: boolean; initialTab?: SlideOverTab; - openSeq?: number; + openRequestKey?: number; }; type CompletionUploadMutate = ( @@ -115,7 +115,7 @@ export function useWorkOrderSlideOver({ onClose, saving, initialTab, - openSeq, + openRequestKey, }: UseWorkOrderSlideOverArgs) { const { user } = useAuthContext(); const workOrderId = row?.id; @@ -165,7 +165,7 @@ export function useWorkOrderSlideOver({ closeDisabled, setTab, initialTab, - openSeq, + 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 581fa6b5..762d5aa4 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 @@ -40,7 +40,7 @@ type WorkOrderSlideOverProps = { /** Comments a notification opened the slide-over for; highlighted on the Comments tab. */ commentHighlight?: CommentHighlight | null; /** Increments on every open so re-opening the same work order re-applies tab/highlight. */ - openSeq?: number; + openRequestKey?: number; }; export function WorkOrderSlideOver({ @@ -60,7 +60,7 @@ export function WorkOrderSlideOver({ saving, initialTab, commentHighlight, - openSeq, + openRequestKey, }: WorkOrderSlideOverProps) { const state = useWorkOrderSlideOver({ row, @@ -69,7 +69,7 @@ export function WorkOrderSlideOver({ onClose, saving, initialTab, - openSeq, + openRequestKey, }); const { infoSource, activeDraft } = state; 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 34e2fbab..3f230e88 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 @@ -53,7 +53,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps editMode={page.slideOverEdit} initialTab={page.slideOverTab} commentHighlight={page.slideOverHighlight} - openSeq={page.slideOverOpenSeq} + openRequestKey={page.slideOverOpenRequestKey} users={users} sites={locations} vendors={vendors} diff --git a/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts index fefe635c..1306fc20 100644 --- a/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts +++ b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts @@ -30,7 +30,7 @@ export function useDashboardDrilldownFilters( } 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-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index eeb5a622..252709cd 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 @@ -26,7 +26,7 @@ function useSlideOverState() { const [highlight, setHighlight] = useState(null); // Bumps on every open() so the slide-over re-applies tab/highlight even when a second // notification targets the work order already on screen (same row id). - const [openSeq, setOpenSeq] = useState(0); + const [openRequestKey, setOpenRequestKey] = useState(0); const open = ( next: WorkOrderTableRow, @@ -38,7 +38,7 @@ function useSlideOverState() { setEdit(nextEdit); setTab(nextTab); setHighlight(nextHighlight); - setOpenSeq((seq) => seq + 1); + setOpenRequestKey((key) => key + 1); }; const close = () => { @@ -47,7 +47,7 @@ function useSlideOverState() { setHighlight(null); }; - return { row, edit, tab, highlight, openSeq, open, close }; + return { row, edit, tab, highlight, openRequestKey, open, close }; } export function useWorkOrdersListPage() { @@ -159,7 +159,7 @@ export function useWorkOrdersListPage() { slideOverEdit: slideOver.edit, slideOverTab: slideOver.tab, slideOverHighlight: slideOver.highlight, - slideOverOpenSeq: slideOver.openSeq, + slideOverOpenRequestKey: slideOver.openRequestKey, confirmCancel, setConfirmCancel, confirmComplete, diff --git a/src/components/notifications/notification-feed-list.tsx b/src/components/notifications/notification-feed-list.tsx index 1a363525..48215231 100644 --- a/src/components/notifications/notification-feed-list.tsx +++ b/src/components/notifications/notification-feed-list.tsx @@ -17,7 +17,7 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) { ); } - if (center.error != null) { + if (center.error != null && !center.hasData) { return ( Notifications could not be loaded. They will retry automatically. diff --git a/src/components/notifications/use-notification-center.ts b/src/components/notifications/use-notification-center.ts index 2463b528..a466b257 100644 --- a/src/components/notifications/use-notification-center.ts +++ b/src/components/notifications/use-notification-center.ts @@ -37,6 +37,7 @@ export function useNotificationCenter(onNavigate?: () => void) { sections, unreadIds, isLoading: feed.isLoading, + hasData: feed.data != null, error: feed.error, open, dismiss: (item: NotificationItem) => dismiss([item.id]), 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/utils/notification-target-url.ts b/src/domain/notifications/utils/notification-target-url.ts index 4d162560..596b5992 100644 --- a/src/domain/notifications/utils/notification-target-url.ts +++ b/src/domain/notifications/utils/notification-target-url.ts @@ -19,7 +19,7 @@ function openQueueUrl(dispatcher: string): string { search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO); search.set("dispatchers", dispatcher); search.set("statuses", OPEN_WIZARD_STATUSES.join(",")); - return workOrderDrilldownUrl(search); + return workOrderDrilldownUrl(search, "notification"); } export function unassignedQueueUrl(): string { 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 index 2916cff6..1b11c20e 100644 --- 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 @@ -142,4 +142,30 @@ describe("board drilldown links", () => { 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 index b76959f3..781ee098 100644 --- a/src/test/components/notifications/notification-center.test.tsx +++ b/src/test/components/notifications/notification-center.test.tsx @@ -271,6 +271,7 @@ describe("notification center", () => { 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(); }); 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"); }); });