diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx index 6792121e..cc623902 100644 --- a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx +++ b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx @@ -33,6 +33,27 @@ export function SlideOverFooter({ onDiscard, onKeepEditing, }: SlideOverFooterProps) { + if (isCancelled) { + return ( +
+ + + This work order has been cancelled and is read-only. + +
+ ); + } + + if (isCompleted) { + return null; + } + if (showUnsaved) { return (
- - - This work order has been cancelled and is read-only. - -
- ); - } - - if (isCompleted && !editing) { - return null; - } - return (
{ + if (!isInfoLocked) return; + setEditing(false); + setShowUnsaved(false); + setUnsavedIntent(null); + const source = infoSource ?? row; + if (source) { + setDraft(source); + setBaseline(source); + } + }, [isInfoLocked, infoSource, row]); + useEffect(() => { if (!infoSource) return; if (!editing) { @@ -90,6 +107,7 @@ export function useSlideOverEditState({ const commit = () => { if (!row || !activeDraft || !dirtyBaseline) return; + if (isSlideOverInfoLocked(infoSource?.status ?? row.status)) return; const patch = buildSlideOverPatch(activeDraft, dirtyBaseline); if (Object.keys(patch).length === 0) { setEditing(false); @@ -145,7 +163,7 @@ export function useSlideOverEditState({ const startEditing = () => { if (!infoSource) return; - if (isWorkOrderFullyLocked(infoSource.status) || isWorkOrderCoreLocked(infoSource.status)) { + if (isSlideOverInfoLocked(infoSource.status)) { return; } setDraft(infoSource); diff --git a/src/domain/work-orders/utils/work-order-lock.ts b/src/domain/work-orders/utils/work-order-lock.ts index 96e99ae9..83d2e74c 100644 --- a/src/domain/work-orders/utils/work-order-lock.ts +++ b/src/domain/work-orders/utils/work-order-lock.ts @@ -29,9 +29,9 @@ export function canMutateWorkOrderExtraDocs(status: string | null | undefined): const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]); /** - * Board PATCH policy for SH-115. Comments and Extra Docs use dedicated endpoints - * (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`). Those requests - * may still fail with 422 `ReadOnly` if the backend lock remains total. + * Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use + * dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`). + * Those requests may still fail with 422 `ReadOnly` if the backend lock remains total. */ export function canApplyWorkOrderTablePatch( status: string | null | undefined, diff --git a/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx b/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx index 8b58ebbe..348fbf48 100644 --- a/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx @@ -3,6 +3,16 @@ import { describe, expect, it, vi } from "vitest"; import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer"; import { renderWithProviders } from "@/test/test-utils"; +const footerHandlers = { + onCancelWO: vi.fn(), + onCancelEdit: vi.fn(), + onClose: vi.fn(), + onEdit: vi.fn(), + onSave: vi.fn(), + onDiscard: vi.fn(), + onKeepEditing: vi.fn(), +}; + describe("SlideOverFooter completed lock", () => { it("hides Edit and Cancel WO when the work order is completed", () => { const { container } = renderWithProviders( @@ -12,13 +22,7 @@ describe("SlideOverFooter completed lock", () => { editing={false} isDirty={false} showUnsaved={false} - onCancelWO={vi.fn()} - onCancelEdit={vi.fn()} - onClose={vi.fn()} - onEdit={vi.fn()} - onSave={vi.fn()} - onDiscard={vi.fn()} - onKeepEditing={vi.fn()} + {...footerHandlers} />, { withAuth: false }, ); @@ -27,4 +31,40 @@ describe("SlideOverFooter completed lock", () => { expect(screen.queryByRole("button", { name: /edit wo/i })).not.toBeInTheDocument(); expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument(); }); + + it("hides Cancel WO and Save when completed while still in edit mode", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + }); + + it("hides the unsaved Save bar when the work order is completed", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByText(/unsaved changes/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx new file mode 100644 index 00000000..bde4cad9 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -0,0 +1,96 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 100, + woNumber: "100", + site: "SITE", + type: "Emergency", + severity: null, + dispatcherId: "d1", + dispatcherName: "Alex", + scheduledOn: "2026-07-16", + completedDate: "", + targetWeek: "", + dayGroup: "2026-07-16", + dayLabel: "Thu", + location: "Main", + locationId: 5, + pm: "", + serviceNotes: "", + extraServices: [], + vendorId: "v1", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "1", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +describe("useSlideOverEditState completed lock", () => { + it("leaves edit mode and refuses commit when the same work order becomes completed", () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + const setTab = vi.fn(); + const open = baseRow(); + + const { result, rerender } = renderHook( + ({ row, infoSource }) => + useSlideOverEditState({ + row, + editMode: true, + infoSource, + onSave, + onClose, + closeDisabled: false, + setTab, + }), + { initialProps: { row: open, infoSource: open } }, + ); + + expect(result.current.editing).toBe(true); + + act(() => { + result.current.handleDraftChange({ woNumber: "WO-LOCKED" }); + }); + + const completed = baseRow({ status: "Completed", woNumber: "100" }); + rerender({ row: completed, infoSource: completed }); + + expect(result.current.editing).toBe(false); + + act(() => { + result.current.commit(); + }); + + expect(onSave).not.toHaveBeenCalled(); + }); +});