diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index fe49083c..3a0601f5 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx index 62234e49..c047bfa5 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx @@ -34,6 +34,7 @@ type SlideOverUpliftsTabProps = { workOrderId: string | number; workOrderType: string; status: WorkOrderTableRow["status"]; + onCreatePendingChange?: (pending: boolean) => void; }; function revokeDialogDescription(amount: number | undefined): string { @@ -71,6 +72,7 @@ export function SlideOverUpliftsTab({ workOrderId, workOrderType, status, + onCreatePendingChange, }: SlideOverUpliftsTabProps) { const { user } = useAuthContext(); const readOnly = isWorkOrderUpliftsReadOnly(status); @@ -134,7 +136,14 @@ export function SlideOverUpliftsTab({ createUplift.mutateAsync(input)} + onSubmit={async (input) => { + onCreatePendingChange?.(true); + try { + await createUplift.mutateAsync(input); + } finally { + onCreatePendingChange?.(false); + } + }} /> )} {showBody && 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 89e002c4..95894acf 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 @@ -132,7 +132,8 @@ export function useWorkOrderSlideOver({ categorizeMediaMutation, }); const completionPending = uploadCompletionMutation.isPending; - const closeDisabled = Boolean(saving || mediaPending || completionPending); + const [upliftCreatePending, setUpliftCreatePending] = useState(false); + const closeDisabled = Boolean(saving || mediaPending || completionPending || upliftCreatePending); const editState = useSlideOverEditState({ row, @@ -176,6 +177,8 @@ export function useWorkOrderSlideOver({ mediaPending, completionPending, closeDisabled, + upliftCreatePending, + setUpliftCreatePending, uploadMediaRequests, uploadExtraFiles, categorizeMedia, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 74c74edd..32b9d21e 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -20,18 +20,31 @@ type SlideOverState = ReturnType; function SlideOverUpliftsPanel({ tab, + editing, infoSource, + activeDraft, + upliftCreatePending, + onUpliftCreatePendingChange, }: { tab: SlideOverTab; + editing: boolean; infoSource: WorkOrderTableRow | null | undefined; + activeDraft: WorkOrderTableRow | null | undefined; + upliftCreatePending: boolean; + onUpliftCreatePendingChange?: (pending: boolean) => void; }) { - if (tab !== "uplifts" || infoSource == null) return null; + const visible = tab === "uplifts"; + if (infoSource == null || (!visible && !upliftCreatePending)) return null; + const source = editing && activeDraft != null ? activeDraft : infoSource; return ( - + ); } @@ -158,7 +171,14 @@ export function WorkOrderSlideOverTabs({ )} - + {tab === "extras" && ( ; + +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAuthContext: () => ({ user: { id: "u1" } }) }; +}); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ + useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), + useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), + useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }), + useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", async () => { + const { useState } = await import("react"); + return { + useWorkOrderUplifts: () => ({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }), + useCreateWorkOrderUplift: () => { + const [isPending, setIsPending] = useState(false); + return { + isPending, + mutateAsync: () => { + setIsPending(true); + return new Promise(() => undefined); + }, + }; + }, + useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-07-20", + completedDate: "", + targetWeek: "", + dayGroup: "07/20", + dayLabel: "Mon", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + company: "Vendor Co", + tech: "Tech", + techPhone: "", + vendorId: "v1", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, + avetaRequired: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +function upliftsState( + editing: boolean, + persisted: Partial, + draft: Partial, +): SlideOverState { + return { + error: null, + detailForRow: undefined, + isLoading: false, + editing, + infoSource: baseRow(persisted), + activeDraft: baseRow(draft), + upliftCreatePending: false, + setUpliftCreatePending: vi.fn(), + } as unknown as SlideOverState; +} + +function renderUplifts(state: SlideOverState) { + return renderWithProviders( + , + ); +} + +describe("WorkOrderSlideOverTabs Uplifts draft", () => { + it("uses the unsaved Info type for the auto-approval allowance", () => { + renderUplifts(upliftsState(true, { type: "PM" }, { type: "Emergency" })); + + expect(screen.getByText(/\$5,000 left to auto-approve/i)).toBeInTheDocument(); + }); + + it("keeps the persisted allowance when the slide-over is not editing", () => { + renderUplifts(upliftsState(false, { type: "PM" }, { type: "Emergency" })); + + expect(screen.getByText(/\$500 left to auto-approve/i)).toBeInTheDocument(); + expect(screen.queryByText(/\$5,000 left to auto-approve/i)).not.toBeInTheDocument(); + }); + + it("closes uplift requests from an unsaved completed status", () => { + renderUplifts(upliftsState(true, { status: "Scheduled" }, { status: "Completed" })); + + expect(screen.getByText(/uplifts can no longer be requested/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /request uplift/i })).not.toBeInTheDocument(); + }); + + it("keeps the request form when a completed status exists only on an inactive draft", () => { + renderUplifts(upliftsState(false, { status: "Scheduled" }, { status: "Completed" })); + + expect(screen.queryByText(/uplifts can no longer be requested/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /request uplift/i })).toBeInTheDocument(); + }); +}); + +describe("WorkOrderSlideOver in-flight uplift create", () => { + it("keeps the request pending and blocks close after leaving the Uplifts tab", async () => { + const row = baseRow(); + renderWithProviders( + , + ); + + fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: "120" } }); + fireEvent.change(screen.getByLabelText(/^notes/i), { target: { value: "Extra parts" } }); + fireEvent.click(screen.getByRole("button", { name: /request uplift/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + }); + + fireEvent.click(screen.getByRole("tab", { name: "Info" })); + + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + const amount = document.getElementById("wo-uplift-amount"); + expect(amount).toHaveValue(120); + expect(amount?.closest("[hidden]")).not.toBeNull(); + expect(screen.getByRole("button", { name: /request uplift/i, hidden: true })).toBeDisabled(); + }); +}); 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 1b11c20e..94f5e51d 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 @@ -86,7 +86,7 @@ describe("work order deep link", () => { }); }); - it("opens the uplifts dialog for an uplifts link instead of the slide-over", async () => { + it("opens the uplifts handler for an uplifts link", async () => { const open = handlers(); const { result } = renderLink("/workorders?wo=42&tab=uplifts", open);