mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into HEAD
# Conflicts: # src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts # src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts # src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx # src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx # src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
This commit is contained in:
commit
75c2c1c1fa
15 changed files with 92 additions and 24 deletions
|
|
@ -29,8 +29,8 @@ type UseSlideOverEditStateArgs = {
|
||||||
setTab: (tab: SlideOverTab) => void;
|
setTab: (tab: SlideOverTab) => void;
|
||||||
/** Tab to land on when a work order opens outside edit mode. */
|
/** Tab to land on when a work order opens outside edit mode. */
|
||||||
initialTab?: SlideOverTab;
|
initialTab?: SlideOverTab;
|
||||||
/** Increments on every open so re-opening the same work order re-applies the requested tab. */
|
/** Changes for each explicit open request, including repeated requests for the same row/tab. */
|
||||||
openSeq?: number;
|
openRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSlideOverEditState({
|
export function useSlideOverEditState({
|
||||||
|
|
@ -42,7 +42,7 @@ export function useSlideOverEditState({
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
setTab,
|
setTab,
|
||||||
initialTab = "info",
|
initialTab = "info",
|
||||||
openSeq,
|
openRequestKey = 0,
|
||||||
}: 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);
|
||||||
|
|
@ -78,8 +78,8 @@ export function useSlideOverEditState({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
setTab(editMode ? "info" : initialTab);
|
setTab(editMode ? "info" : initialTab);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- openSeq gates each open
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open
|
||||||
}, [openSeq]);
|
}, [openRequestKey]);
|
||||||
|
|
||||||
const lockStatus = infoSource?.status ?? row?.status;
|
const lockStatus = infoSource?.status ?? row?.status;
|
||||||
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ type UseWorkOrderSlideOverArgs = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
initialTab?: SlideOverTab;
|
initialTab?: SlideOverTab;
|
||||||
openSeq?: number;
|
openRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CompletionUploadMutate = (
|
type CompletionUploadMutate = (
|
||||||
|
|
@ -115,7 +115,7 @@ export function useWorkOrderSlideOver({
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
initialTab,
|
initialTab,
|
||||||
openSeq,
|
openRequestKey,
|
||||||
}: UseWorkOrderSlideOverArgs) {
|
}: UseWorkOrderSlideOverArgs) {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const workOrderId = row?.id;
|
const workOrderId = row?.id;
|
||||||
|
|
@ -165,7 +165,7 @@ export function useWorkOrderSlideOver({
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
setTab,
|
setTab,
|
||||||
initialTab,
|
initialTab,
|
||||||
openSeq,
|
openRequestKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadCompletionPdf = (file: File) => {
|
const uploadCompletionPdf = (file: File) => {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ type WorkOrderSlideOverProps = {
|
||||||
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
|
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
|
||||||
commentHighlight?: CommentHighlight | null;
|
commentHighlight?: CommentHighlight | null;
|
||||||
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
|
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
|
||||||
openSeq?: number;
|
openRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderSlideOver({
|
export function WorkOrderSlideOver({
|
||||||
|
|
@ -60,7 +60,7 @@ export function WorkOrderSlideOver({
|
||||||
saving,
|
saving,
|
||||||
initialTab,
|
initialTab,
|
||||||
commentHighlight,
|
commentHighlight,
|
||||||
openSeq,
|
openRequestKey,
|
||||||
}: WorkOrderSlideOverProps) {
|
}: WorkOrderSlideOverProps) {
|
||||||
const state = useWorkOrderSlideOver({
|
const state = useWorkOrderSlideOver({
|
||||||
row,
|
row,
|
||||||
|
|
@ -69,7 +69,7 @@ export function WorkOrderSlideOver({
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
initialTab,
|
initialTab,
|
||||||
openSeq,
|
openRequestKey,
|
||||||
});
|
});
|
||||||
const { infoSource, activeDraft } = state;
|
const { infoSource, activeDraft } = state;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
||||||
editMode={page.slideOverEdit}
|
editMode={page.slideOverEdit}
|
||||||
initialTab={page.slideOverTab}
|
initialTab={page.slideOverTab}
|
||||||
commentHighlight={page.slideOverHighlight}
|
commentHighlight={page.slideOverHighlight}
|
||||||
openSeq={page.slideOverOpenSeq}
|
openRequestKey={page.slideOverOpenRequestKey}
|
||||||
users={users}
|
users={users}
|
||||||
sites={locations}
|
sites={locations}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function useDashboardDrilldownFilters(
|
||||||
}
|
}
|
||||||
|
|
||||||
appliedRef.current = true;
|
appliedRef.current = true;
|
||||||
setFromDashboard(true);
|
setFromDashboard(searchParams.get("fromDashboard") === "1");
|
||||||
onApplyRef.current(drilldown);
|
onApplyRef.current(drilldown);
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function useSlideOverState() {
|
||||||
const [highlight, setHighlight] = useState<CommentHighlight | null>(null);
|
const [highlight, setHighlight] = useState<CommentHighlight | null>(null);
|
||||||
// Bumps on every open() so the slide-over re-applies tab/highlight even when a second
|
// 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).
|
// notification targets the work order already on screen (same row id).
|
||||||
const [openSeq, setOpenSeq] = useState(0);
|
const [openRequestKey, setOpenRequestKey] = useState(0);
|
||||||
|
|
||||||
const open = (
|
const open = (
|
||||||
next: WorkOrderTableRow,
|
next: WorkOrderTableRow,
|
||||||
|
|
@ -38,7 +38,7 @@ function useSlideOverState() {
|
||||||
setEdit(nextEdit);
|
setEdit(nextEdit);
|
||||||
setTab(nextTab);
|
setTab(nextTab);
|
||||||
setHighlight(nextHighlight);
|
setHighlight(nextHighlight);
|
||||||
setOpenSeq((seq) => seq + 1);
|
setOpenRequestKey((key) => key + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|
@ -47,7 +47,7 @@ function useSlideOverState() {
|
||||||
setHighlight(null);
|
setHighlight(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { row, edit, tab, highlight, openSeq, open, close };
|
return { row, edit, tab, highlight, openRequestKey, open, close };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkOrdersListPage() {
|
export function useWorkOrdersListPage() {
|
||||||
|
|
@ -159,7 +159,7 @@ export function useWorkOrdersListPage() {
|
||||||
slideOverEdit: slideOver.edit,
|
slideOverEdit: slideOver.edit,
|
||||||
slideOverTab: slideOver.tab,
|
slideOverTab: slideOver.tab,
|
||||||
slideOverHighlight: slideOver.highlight,
|
slideOverHighlight: slideOver.highlight,
|
||||||
slideOverOpenSeq: slideOver.openSeq,
|
slideOverOpenRequestKey: slideOver.openRequestKey,
|
||||||
confirmCancel,
|
confirmCancel,
|
||||||
setConfirmCancel,
|
setConfirmCancel,
|
||||||
confirmComplete,
|
confirmComplete,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (center.error != null) {
|
if (center.error != null && !center.hasData) {
|
||||||
return (
|
return (
|
||||||
<Text variant="error" className="py-6">
|
<Text variant="error" className="py-6">
|
||||||
Notifications could not be loaded. They will retry automatically.
|
Notifications could not be loaded. They will retry automatically.
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
sections,
|
sections,
|
||||||
unreadIds,
|
unreadIds,
|
||||||
isLoading: feed.isLoading,
|
isLoading: feed.isLoading,
|
||||||
|
hasData: feed.data != null,
|
||||||
error: feed.error,
|
error: feed.error,
|
||||||
open,
|
open,
|
||||||
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
|
||||||
(status) => status !== "Completed",
|
(status) => status !== "Completed",
|
||||||
);
|
);
|
||||||
|
|
||||||
export function workOrderDrilldownUrl(search: URLSearchParams): string {
|
export function workOrderDrilldownUrl(
|
||||||
const query = search.toString();
|
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;
|
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ function openQueueUrl(dispatcher: string): string {
|
||||||
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
|
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
|
||||||
search.set("dispatchers", dispatcher);
|
search.set("dispatchers", dispatcher);
|
||||||
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
||||||
return workOrderDrilldownUrl(search);
|
return workOrderDrilldownUrl(search, "notification");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unassignedQueueUrl(): string {
|
export function unassignedQueueUrl(): string {
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import DashboardPage from "@/app/(protected)/dashboard";
|
||||||
import {
|
import {
|
||||||
avetaPendingDrilldownSearch,
|
avetaPendingDrilldownSearch,
|
||||||
scheduledTomorrowDrilldownSearch,
|
scheduledTomorrowDrilldownSearch,
|
||||||
|
workOrderDrilldownUrl,
|
||||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||||
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
|
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
|
||||||
|
|
||||||
|
|
@ -161,7 +162,7 @@ describe("DashboardPage", () => {
|
||||||
|
|
||||||
const today = businessTodayIso();
|
const today = businessTodayIso();
|
||||||
expect(navigate).toHaveBeenCalledWith(
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
|
workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)),
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state";
|
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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
import {
|
||||||
vendorAssignedMessage,
|
vendorAssignedMessage,
|
||||||
|
|
@ -111,6 +112,34 @@ describe("useSlideOverEditState completed lock", () => {
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
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", () => {
|
it("toasts vendor assigned after a successful save that includes vendorId", () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
const open = baseRow();
|
const open = baseRow();
|
||||||
|
|
|
||||||
|
|
@ -142,4 +142,30 @@ describe("board drilldown links", () => {
|
||||||
customTo: "2099-12-31",
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,7 @@ describe("notification center", () => {
|
||||||
expect(url.searchParams.get("dateFrom")).toBe("1970-01-01");
|
expect(url.searchParams.get("dateFrom")).toBe("1970-01-01");
|
||||||
expect(url.searchParams.get("dateTo")).toBe("2099-12-31");
|
expect(url.searchParams.get("dateTo")).toBe("2099-12-31");
|
||||||
expect(url.searchParams.get("statuses")).toBeTruthy();
|
expect(url.searchParams.get("statuses")).toBeTruthy();
|
||||||
|
expect(url.searchParams.get("fromDashboard")).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,12 @@ describe("work-order drilldown links", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders plain or query-bearing work order URLs", () => {
|
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(
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue