mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 18:12:05 +00:00
Merge pull request #231 from Sea-Haven-Industries/fix/ab/sh-380-completion-pdf-post-upload-error
fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
This commit is contained in:
commit
7c470d389c
5 changed files with 319 additions and 32 deletions
|
|
@ -97,12 +97,13 @@ export function CompDocDialog({
|
||||||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||||
|
|
||||||
// Uploading a signed PDF finalizes through the same Generate flow, so the
|
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
|
||||||
// completedDate/address prerequisites still persist before docStatus Yes.
|
// 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({
|
const upload = useCompDocPdfUpload({
|
||||||
workOrderId: row.id,
|
workOrderId: row.id,
|
||||||
rowVersion,
|
rowVersion,
|
||||||
onUploaded: dialog.handleGenerate,
|
onUploaded: dialog.markSignedPdfUploaded,
|
||||||
});
|
});
|
||||||
|
|
||||||
// A signed PDF on file means the document exists even if docState has not caught up.
|
// A signed PDF on file means the document exists even if docState has not caught up.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
isWorkOrderFullyLocked,
|
isWorkOrderFullyLocked,
|
||||||
} from "@/domain/work-orders/utils/work-order-lock";
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
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 { useAuthContext } from "@/providers/auth-context";
|
||||||
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
||||||
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
||||||
|
|
@ -37,22 +36,17 @@ type UseWorkOrderSlideOverArgs = {
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CompletionUploadMutate = (
|
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
|
||||||
input: { file: File; workOrderVersion: string },
|
|
||||||
options: { onSuccess: () => Promise<void> | void },
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
function uploadSlideOverCompletionPdf({
|
function uploadSlideOverCompletionPdf({
|
||||||
file,
|
file,
|
||||||
row,
|
row,
|
||||||
infoSource,
|
infoSource,
|
||||||
onSave,
|
|
||||||
mutate,
|
mutate,
|
||||||
}: {
|
}: {
|
||||||
file: File;
|
file: File;
|
||||||
row: WorkOrderTableRow | null;
|
row: WorkOrderTableRow | null;
|
||||||
infoSource: WorkOrderTableRow | null | undefined;
|
infoSource: WorkOrderTableRow | null | undefined;
|
||||||
onSave: UseWorkOrderSlideOverArgs["onSave"];
|
|
||||||
mutate: CompletionUploadMutate;
|
mutate: CompletionUploadMutate;
|
||||||
}) {
|
}) {
|
||||||
const version = infoSource?.rowVersion;
|
const version = infoSource?.rowVersion;
|
||||||
|
|
@ -60,27 +54,11 @@ function uploadSlideOverCompletionPdf({
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mutate(
|
// The upload transitions the completion document to Yes and bumps the work-order row
|
||||||
{ file, workOrderVersion: version },
|
// 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
|
||||||
onSuccess: async () => {
|
// successful upload, so it is intentionally omitted.
|
||||||
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
mutate({ file, workOrderVersion: version });
|
||||||
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.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSlideOverSource(
|
function resolveSlideOverSource(
|
||||||
|
|
@ -167,7 +145,6 @@ export function useWorkOrderSlideOver({
|
||||||
file,
|
file,
|
||||||
row,
|
row,
|
||||||
infoSource,
|
infoSource,
|
||||||
onSave,
|
|
||||||
mutate: uploadCompletionMutation.mutate,
|
mutate: uploadCompletionMutation.mutate,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -162,11 +162,22 @@ export function createCompDocDialogActions({
|
||||||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
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 {
|
return {
|
||||||
handleMarkNotRequired,
|
handleMarkNotRequired,
|
||||||
handleUndo,
|
handleUndo,
|
||||||
handleSaveDraft,
|
handleSaveDraft,
|
||||||
handleGenerate,
|
handleGenerate,
|
||||||
handleDownloadPdf,
|
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