mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): use completedDate in CompDoc without lifecycle Completed
This commit is contained in:
parent
e1daf94be2
commit
5ea79ce8d1
12 changed files with 260 additions and 55 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue