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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,10 @@ import {
expandTablePatchToBoardFieldOps, expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch, isLocalOnlyTablePatch,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; } 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 { import type {
WorkOrderTablePatch, WorkOrderTablePatch,
WorkOrderTableRow, WorkOrderTableRow,
@ -28,6 +31,96 @@ export type WorkOrderTablePatchHandlers = {
getRow?: (id: string | number) => WorkOrderTableRow | undefined; 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( export function useWorkOrderTableMutations(
onPatchOrHandlers: onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers, ((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
@ -50,47 +143,8 @@ export function useWorkOrderTableMutations(
}; };
const patchMutation = useMutation({ const patchMutation = useMutation({
mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => { mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
if (isLocalOnlyTablePatch(patch)) { runWorkOrderTablePatch(id, patch, getRow),
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 };
},
onMutate: async ({ id, patch }) => { onMutate: async ({ id, patch }) => {
onPatch(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", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20", scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "", targetWeek: "",
dayGroup: "07/20", dayGroup: "07/20",
dayLabel: "Mon", dayLabel: "Mon",

View file

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

View file

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

View file

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

View file

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