fix(work-orders): drop slide-over edit actions after complete

Exit edit mode and refuse core Save when status becomes Completed; hide the footer even if unsaved.
This commit is contained in:
Arthur Bassi 2026-08-21 16:24:48 -03:00
parent 18d80a3c47
commit 260fe6c654
5 changed files with 189 additions and 35 deletions

View file

@ -33,6 +33,27 @@ export function SlideOverFooter({
onDiscard,
onKeepEditing,
}: SlideOverFooterProps) {
if (isCancelled) {
return (
<div
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
style={{
background: "var(--color-bg-muted)",
borderTop: "1px solid var(--color-border)",
}}
>
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</Text>
</div>
);
}
if (isCompleted) {
return null;
}
if (showUnsaved) {
return (
<div
@ -70,27 +91,6 @@ export function SlideOverFooter({
);
}
if (isCancelled) {
return (
<div
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
style={{
background: "var(--color-bg-muted)",
borderTop: "1px solid var(--color-border)",
}}
>
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</Text>
</div>
);
}
if (isCompleted && !editing) {
return null;
}
return (
<div
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"

View file

@ -14,6 +14,10 @@ import {
import type { SlideOverSaveOptions } from "./work-order-slide-over";
import type { SlideOverTab } from "./use-work-order-slide-over";
function isSlideOverInfoLocked(status: string | null | undefined): boolean {
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
}
type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
@ -53,9 +57,7 @@ export function useSlideOverEditState({
}
setDraft(row);
setBaseline(row);
setEditing(
editMode && !isWorkOrderFullyLocked(row.status) && !isWorkOrderCoreLocked(row.status),
);
setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab("info");
setShowUnsaved(false);
setUnsavedIntent(null);
@ -63,6 +65,21 @@ export function useSlideOverEditState({
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
useEffect(() => {
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);

View file

@ -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,

View file

@ -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(
<SlideOverFooter
isCancelled={false}
isCompleted
editing
isDirty
showUnsaved={false}
{...footerHandlers}
/>,
{ 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(
<SlideOverFooter
isCancelled={false}
isCompleted
editing
isDirty
showUnsaved
{...footerHandlers}
/>,
{ withAuth: false },
);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByText(/unsaved changes/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
});
});

View file

@ -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> = {}): 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();
});
});