mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 19:32:06 +00:00
fix(work-orders): keep in-flight uplift requests on the slide-over
Point notification deep links at the Uplifts tab and follow the unsaved Info draft. Block close while an evidence scan is still running.
This commit is contained in:
parent
9bcaadc555
commit
144fc0792c
6 changed files with 251 additions and 10 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 144 KiB |
|
|
@ -35,6 +35,7 @@ type SlideOverUpliftsTabProps = {
|
||||||
workOrderId: string | number;
|
workOrderId: string | number;
|
||||||
workOrderType: string;
|
workOrderType: string;
|
||||||
status: WorkOrderTableRow["status"];
|
status: WorkOrderTableRow["status"];
|
||||||
|
onCreatePendingChange?: (pending: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function revokeDialogDescription(amount: number | undefined): string {
|
function revokeDialogDescription(amount: number | undefined): string {
|
||||||
|
|
@ -72,6 +73,7 @@ export function SlideOverUpliftsTab({
|
||||||
workOrderId,
|
workOrderId,
|
||||||
workOrderType,
|
workOrderType,
|
||||||
status,
|
status,
|
||||||
|
onCreatePendingChange,
|
||||||
}: SlideOverUpliftsTabProps) {
|
}: SlideOverUpliftsTabProps) {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const readOnly = isWorkOrderUpliftsReadOnly(status);
|
const readOnly = isWorkOrderUpliftsReadOnly(status);
|
||||||
|
|
@ -135,7 +137,14 @@ export function SlideOverUpliftsTab({
|
||||||
<WorkOrderUpliftCreateForm
|
<WorkOrderUpliftCreateForm
|
||||||
pending={createUplift.isPending}
|
pending={createUplift.isPending}
|
||||||
remainingAllowance={getRemainingUpliftAllowance(workOrderType, uplifts)}
|
remainingAllowance={getRemainingUpliftAllowance(workOrderType, uplifts)}
|
||||||
onSubmit={(input) => createUplift.mutateAsync(input)}
|
onSubmit={async (input) => {
|
||||||
|
onCreatePendingChange?.(true);
|
||||||
|
try {
|
||||||
|
await createUplift.mutateAsync(input);
|
||||||
|
} finally {
|
||||||
|
onCreatePendingChange?.(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showBody &&
|
{showBody &&
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,8 @@ export function useWorkOrderSlideOver({
|
||||||
categorizeMediaMutation,
|
categorizeMediaMutation,
|
||||||
});
|
});
|
||||||
const completionPending = uploadCompletionMutation.isPending;
|
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({
|
const editState = useSlideOverEditState({
|
||||||
row,
|
row,
|
||||||
|
|
@ -185,6 +186,8 @@ export function useWorkOrderSlideOver({
|
||||||
mediaPending,
|
mediaPending,
|
||||||
completionPending,
|
completionPending,
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
|
upliftCreatePending,
|
||||||
|
setUpliftCreatePending,
|
||||||
uploadMediaRequests,
|
uploadMediaRequests,
|
||||||
uploadExtraFiles,
|
uploadExtraFiles,
|
||||||
categorizeMedia,
|
categorizeMedia,
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,31 @@ type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
|
||||||
|
|
||||||
function SlideOverUpliftsPanel({
|
function SlideOverUpliftsPanel({
|
||||||
tab,
|
tab,
|
||||||
|
editing,
|
||||||
infoSource,
|
infoSource,
|
||||||
|
activeDraft,
|
||||||
|
upliftCreatePending,
|
||||||
|
onUpliftCreatePendingChange,
|
||||||
}: {
|
}: {
|
||||||
tab: SlideOverTab;
|
tab: SlideOverTab;
|
||||||
|
editing: boolean;
|
||||||
infoSource: WorkOrderTableRow | null | undefined;
|
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 (
|
return (
|
||||||
<SlideOverUpliftsTab
|
<div hidden={!visible}>
|
||||||
workOrderId={infoSource.id}
|
<SlideOverUpliftsTab
|
||||||
workOrderType={infoSource.type}
|
workOrderId={infoSource.id}
|
||||||
status={infoSource.status}
|
workOrderType={source.type}
|
||||||
/>
|
status={source.status}
|
||||||
|
onCreatePendingChange={onUpliftCreatePendingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +157,14 @@ function SecondarySlideOverTabs({
|
||||||
</WorkOrderSlideOverTabPanel>
|
</WorkOrderSlideOverTabPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SlideOverUpliftsPanel tab={tab} infoSource={infoSource} />
|
<SlideOverUpliftsPanel
|
||||||
|
tab={tab}
|
||||||
|
editing={Boolean(state.editing)}
|
||||||
|
infoSource={infoSource}
|
||||||
|
activeDraft={activeDraft}
|
||||||
|
upliftCreatePending={Boolean(state.upliftCreatePending)}
|
||||||
|
onUpliftCreatePendingChange={state.setUpliftCreatePending}
|
||||||
|
/>
|
||||||
|
|
||||||
{tab === "extras" && (
|
{tab === "extras" && (
|
||||||
<WorkOrderSlideOverTabPanel
|
<WorkOrderSlideOverTabPanel
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||||
|
import { WorkOrderSlideOverTabs } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs";
|
||||||
|
import type { useWorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
|
||||||
|
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> = {}): 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<WorkOrderTableRow>,
|
||||||
|
draft: Partial<WorkOrderTableRow>,
|
||||||
|
): 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(
|
||||||
|
<WorkOrderSlideOverTabs
|
||||||
|
tab="uplifts"
|
||||||
|
state={state}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
allOrders={[]}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<WorkOrderSlideOver
|
||||||
|
row={row}
|
||||||
|
editMode={false}
|
||||||
|
initialTab="uplifts"
|
||||||
|
users={[{ id: "u1", name: "Dispatcher" }]}
|
||||||
|
sites={[{ id: "loc-1", name: "Seattle" }]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
allOrders={[row]}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
onCancelWO={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 open = handlers();
|
||||||
const { result } = renderLink("/workorders?wo=42&tab=uplifts", open);
|
const { result } = renderLink("/workorders?wo=42&tab=uplifts", open);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue