mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 11:33:12 +00:00
fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
The completion-doc upload endpoint sets DocStatus=Yes and bumps the work-order row version in one transaction. Both signed-PDF upload paths then re-ran a "docStatus=Yes" work-order patch using the pre-upload row version (the comp-doc dialog re-ran the whole Generate flow; the slide-over posted docStatus directly). That patch always 409'd — and in the dialog it also demanded a Date Work Completed — so a successful upload surfaced an error to the user. Both paths now stop after the upload settles: the mutation's onSuccess refreshes the board and the UI reflects the generated state locally, with no second docStatus patch.
This commit is contained in:
parent
95f0342271
commit
8d50c6f75e
5 changed files with 319 additions and 32 deletions
|
|
@ -97,12 +97,13 @@ export function CompDocDialog({
|
|||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||
|
||||
// Uploading a signed PDF finalizes through the same Generate flow, so the
|
||||
// completedDate/address prerequisites still persist before docStatus Yes.
|
||||
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
|
||||
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
|
||||
// Generate patch would carry the pre-upload version and 409 after a successful upload.
|
||||
const upload = useCompDocPdfUpload({
|
||||
workOrderId: row.id,
|
||||
rowVersion,
|
||||
onUploaded: dialog.handleGenerate,
|
||||
onUploaded: dialog.markSignedPdfUploaded,
|
||||
});
|
||||
|
||||
// A signed PDF on file means the document exists even if docState has not caught up.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
isWorkOrderFullyLocked,
|
||||
} from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
||||
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
||||
|
|
@ -37,22 +36,17 @@ type UseWorkOrderSlideOverArgs = {
|
|||
saving?: boolean;
|
||||
};
|
||||
|
||||
type CompletionUploadMutate = (
|
||||
input: { file: File; workOrderVersion: string },
|
||||
options: { onSuccess: () => Promise<void> | void },
|
||||
) => void;
|
||||
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
|
||||
|
||||
function uploadSlideOverCompletionPdf({
|
||||
file,
|
||||
row,
|
||||
infoSource,
|
||||
onSave,
|
||||
mutate,
|
||||
}: {
|
||||
file: File;
|
||||
row: WorkOrderTableRow | null;
|
||||
infoSource: WorkOrderTableRow | null | undefined;
|
||||
onSave: UseWorkOrderSlideOverArgs["onSave"];
|
||||
mutate: CompletionUploadMutate;
|
||||
}) {
|
||||
const version = infoSource?.rowVersion;
|
||||
|
|
@ -60,27 +54,11 @@ function uploadSlideOverCompletionPdf({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
mutate(
|
||||
{ file, workOrderVersion: version },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
||||
if (!infoSource.completedDate) {
|
||||
patch.completedDate = todayIso();
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
onSave(row.id, patch, {
|
||||
onSuccess: () => resolve(),
|
||||
onError: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
// The upload transitions the completion document to Yes and bumps the work-order row
|
||||
// version in the same server transaction; the mutation's onSuccess refreshes the board.
|
||||
// A client docStatus patch here would carry the pre-upload version and 409 after a
|
||||
// successful upload, so it is intentionally omitted.
|
||||
mutate({ file, workOrderVersion: version });
|
||||
}
|
||||
|
||||
function resolveSlideOverSource(
|
||||
|
|
@ -167,7 +145,6 @@ export function useWorkOrderSlideOver({
|
|||
file,
|
||||
row,
|
||||
infoSource,
|
||||
onSave,
|
||||
mutate: uploadCompletionMutation.mutate,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -162,11 +162,22 @@ export function createCompDocDialogActions({
|
|||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||
}
|
||||
|
||||
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
|
||||
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
|
||||
// authoritative state, but do not re-run the Generate persist — that patch would carry the
|
||||
// pre-upload row version and 409, surfacing an error right after a successful upload.
|
||||
function markSignedPdfUploaded() {
|
||||
setGeneratedAt(fmtDocNow());
|
||||
setDocState("generated");
|
||||
refreshAuthoritativeState();
|
||||
}
|
||||
|
||||
return {
|
||||
handleMarkNotRequired,
|
||||
handleUndo,
|
||||
handleSaveDraft,
|
||||
handleGenerate,
|
||||
handleDownloadPdf,
|
||||
markSignedPdfUploaded,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type SaveHandler = (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
|
||||
// A completion-doc upload that settles successfully. The backend has already set
|
||||
// DocStatus=Yes and bumped the work-order row version inside the same transaction,
|
||||
// so any client follow-up patch would carry a stale version.
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||
useUploadCompletionDoc: () => ({
|
||||
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
|
||||
void options?.onSuccess?.();
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
|
||||
showCompDocDraftSavedToast: vi.fn(),
|
||||
}));
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
const defaults: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
completedDate: "2026-07-01",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Seattle",
|
||||
locationId: "mock-loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "Vendor Co",
|
||||
tech: "Tech One",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "Amazon Rep",
|
||||
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,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
|
||||
describe("CompDocDialog signed PDF upload (SH-380)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.error).mockReset();
|
||||
});
|
||||
|
||||
function uploadSignedPdf(onSave: SaveHandler) {
|
||||
const onDateCompletedChange = vi.fn().mockResolvedValue(undefined);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompDocDialog
|
||||
row={baseRow()}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
expect(fileInput).not.toBeNull();
|
||||
fireEvent.change(fileInput!, {
|
||||
target: {
|
||||
files: [
|
||||
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
|
||||
type: "application/pdf",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return { onSave, onDateCompletedChange };
|
||||
}
|
||||
|
||||
it("does not re-run the docStatus persist after a successful upload", async () => {
|
||||
// The server already marked the document generated; a client docStatus patch here would
|
||||
// carry the pre-upload row version and 409. It must not run.
|
||||
const onSave: SaveHandler = vi.fn(() =>
|
||||
Promise.reject(new Error("The record was modified by another user. Refresh and retry.")),
|
||||
);
|
||||
|
||||
uploadSignedPdf(onSave);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces no error toast to the user when the signed PDF upload settles", async () => {
|
||||
const onSave: SaveHandler = vi.fn(() => Promise.reject(new Error("conflict")));
|
||||
|
||||
const { onDateCompletedChange } = uploadSignedPdf(onSave);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(onDateCompletedChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
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(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
|
||||
// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch).
|
||||
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: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
|
||||
void options?.onSuccess?.();
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
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,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkOrderSlideOver signed PDF upload (SH-380)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not issue a docStatus patch or surface an error after a successful upload", async () => {
|
||||
const row = baseRow();
|
||||
// A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs.
|
||||
const onSave = vi.fn(
|
||||
(
|
||||
_id: string | number,
|
||||
_patch: Partial<WorkOrderTableRow>,
|
||||
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
|
||||
) => {
|
||||
options?.onError?.(new Error("The record was modified by another user."));
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<WorkOrderSlideOver
|
||||
row={row}
|
||||
editMode={false}
|
||||
users={[{ id: "u1", name: "Dispatcher" }]}
|
||||
sites={[{ id: "loc-1", name: "Seattle" }]}
|
||||
vendors={[]}
|
||||
pmTypes={["HVAC"]}
|
||||
allOrders={[row]}
|
||||
onSave={onSave}
|
||||
onOpenCompletionDoc={vi.fn()}
|
||||
onCancelWO={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /completion doc/i }));
|
||||
|
||||
const pdfInput = document.querySelector('input[accept="application/pdf,.pdf"]');
|
||||
expect(pdfInput).not.toBeNull();
|
||||
fireEvent.change(pdfInput!, {
|
||||
target: {
|
||||
files: [
|
||||
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
|
||||
type: "application/pdf",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue