fix(work-orders): use completedDate in CompDoc without lifecycle Completed

This commit is contained in:
Arthur Bassi 2026-07-30 11:53:33 -03:00 • committed by arthur.bassi
parent e1daf94be2
commit 5ea79ce8d1
12 changed files with 260 additions and 55 deletions

View file

@ -4,7 +4,6 @@ import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdro
import { Text } from "@/components/ui/text";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
@ -18,7 +17,6 @@ type CompDocDialogProps = {
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onStatusChange?: (status: WizardWOStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
@ -28,7 +26,6 @@ export function CompDocDialog({
onOpenChange,
data,
onSave,
onStatusChange,
onDateCompletedChange,
}: CompDocDialogProps) {
const dialog = useCompDocDialog({
@ -36,7 +33,6 @@ export function CompDocDialog({
open,
data,
onSave,
onStatusChange,
onDateCompletedChange,
});
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);

View file

@ -8,7 +8,6 @@ import type {
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
@ -25,7 +24,6 @@ type UseCompDocDialogArgs = {
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onStatusChange?: (status: WizardWOStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
@ -61,7 +59,6 @@ export function useCompDocDialog({
open,
data,
onSave,
onStatusChange,
onDateCompletedChange,
}: UseCompDocDialogArgs) {
const locId = String(row.locationId || "");
@ -90,12 +87,12 @@ export function useCompDocDialog({
setNotes(data?.notes ?? "");
setDocState(docStateOf(completion?.docStatus || info.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(info.scheduledOn || "");
setEditDateCompleted(info.completedDate || "");
}, [
open,
info.id,
info.docStatus,
info.scheduledOn,
info.completedDate,
completion?.docStatus,
data?.notes,
data?.generatedAt,
@ -122,7 +119,7 @@ export function useCompDocDialog({
};
const syncDateIfChanged = () => {
if (editDateCompleted !== info.scheduledOn) {
if (editDateCompleted !== (info.completedDate || "")) {
onDateCompletedChange?.(editDateCompleted);
}
};
@ -149,7 +146,6 @@ export function useCompDocDialog({
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
syncDateIfChanged();
onStatusChange?.("Completed");
}
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({

View file

@ -148,8 +148,9 @@ export default function WorkOrdersListPage() {
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onStatusChange={(status) => page.patchField(activeDocRow.id, { status })}
onDateCompletedChange={(date) => page.patchField(activeDocRow.id, { scheduledOn: date })}
onDateCompletedChange={(date) =>
page.patchField(activeDocRow.id, { completedDate: date })
}
/>
)}
</Box>

View file

@ -237,6 +237,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
dayGroup,
dayLabel,

View file

@ -12,6 +12,8 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
completedDate: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;

View file

@ -11,7 +11,10 @@ import {
expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import {
mapWorkOrderDetailToFormValues,
mapWorkOrderTableRow,
} from "@/domain/work-orders/mappers/work-order-mapper";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
@ -28,6 +31,96 @@ export type WorkOrderTablePatchHandlers = {
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
};
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
async function persistCompletedDate(id: string | number, completedDate: string): Promise<void> {
const detail = await workOrdersApi.getById(id);
const values = mapWorkOrderDetailToFormValues(detail);
await workOrdersApi.update(id, { ...values, completedDate });
}
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<WorkOrderTableRow> {
const ops = expandTablePatchToBoardFieldOps(patch);
if (ops.length === 0) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
const current = getRow?.(id);
if (!current?.rowVersion) {
throw new Error("Missing work order version for board patch. Refresh and retry.");
}
let latest = current;
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
}
throw error;
}
}
return latest;
}
/** CompDoc completedDate uses EditWorkorder; board fields use PATCH …/board. */
async function runWorkOrderTablePatch(
id: string | number,
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<PatchResult> {
if (isLocalOnlyTablePatch(patch)) {
return { kind: "local", patch };
}
const completedDate = patch.completedDate;
const boardPatch: WorkOrderTablePatch = { ...patch };
delete boardPatch.completedDate;
let boardRow: WorkOrderTableRow | undefined;
const hasBoardKeys = Object.keys(boardPatch).length > 0;
if (hasBoardKeys && !isLocalOnlyTablePatch(boardPatch)) {
boardRow = await applyBoardFieldOps(id, boardPatch, getRow);
} else if (!hasBoardKeys && completedDate === undefined) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
if (completedDate !== undefined) {
await persistCompletedDate(id, completedDate);
}
if (boardRow) {
return {
kind: "board",
row: completedDate !== undefined ? { ...boardRow, completedDate } : boardRow,
};
}
return { kind: "local", patch };
}
export function useWorkOrderTableMutations(
onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
@ -50,47 +143,8 @@ export function useWorkOrderTableMutations(
};
const patchMutation = useMutation({
mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => {
if (isLocalOnlyTablePatch(patch)) {
return { kind: "local" as const, patch };
}
const ops = expandTablePatchToBoardFieldOps(patch);
if (ops.length === 0) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
const current = getRow?.(id);
if (!current?.rowVersion) {
throw new Error("Missing work order version for board patch. Refresh and retry.");
}
let latest = current;
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
}
throw error;
}
}
return { kind: "board" as const, row: latest };
},
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
runWorkOrderTablePatch(id, patch, getRow),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},

View file

@ -0,0 +1,150 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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 }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("react-toastify", () => ({
toast: vi.fn(),
}));
const SCHEDULED_ON = "2026-06-01";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: SCHEDULED_ON,
completedDate: "",
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: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
describe("CompDocDialog Save Draft / Generate completion contract", () => {
const onSave = vi.fn();
const onDateCompletedChange = vi.fn();
beforeEach(() => {
onSave.mockReset();
onDateCompletedChange.mockReset();
});
function renderDialog(row: WorkOrderTableRow) {
// Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
const patches: Array<Partial<WorkOrderTableRow>> = [];
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<CompDocDialog
row={row}
open
onOpenChange={vi.fn()}
onSave={(data, status) => {
onSave(data, status);
patches.push({ docStatus: status });
}}
onDateCompletedChange={(date) => {
onDateCompletedChange(date);
patches.push({ completedDate: date });
}}
/>
</QueryClientProvider>,
);
return { patches };
}
function changeDateCompleted(value: string) {
const dateInput = document.querySelector('input[type="date"]');
expect(dateInput).not.toBeNull();
fireEvent.change(dateInput!, { target: { value } });
}
it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", () => {
const row = baseRow();
const { patches } = renderDialog(row);
changeDateCompleted("2026-07-15");
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]);
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
expect(row.scheduledOn).toBe(SCHEDULED_ON);
expect(row.status).toBe("Scheduled");
});
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", () => {
const row = baseRow({ completedDate: "2026-07-01" });
const { patches } = renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ generatedAt: expect.any(String) }),
"Yes",
);
expect(patches).toEqual([{ docStatus: "Yes" }, { completedDate: "2026-07-20" }]);
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
expect(row.scheduledOn).toBe(SCHEDULED_ON);
expect(row.status).toBe("Scheduled");
});
});

View file

@ -55,6 +55,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",

View file

@ -75,6 +75,7 @@ const canceledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "2026-07-20",
dayGroup: "2026-07-20",
dayLabel: "Mon",

View file

@ -15,6 +15,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",

View file

@ -37,6 +37,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",

View file

@ -19,6 +19,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",
completedDate: "",
targetWeek: "",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",