mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 05:52:04 +00:00
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:
parent
18d80a3c47
commit
260fe6c654
5 changed files with 189 additions and 35 deletions
|
|
@ -33,6 +33,27 @@ export function SlideOverFooter({
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onKeepEditing,
|
onKeepEditing,
|
||||||
}: SlideOverFooterProps) {
|
}: 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) {
|
if (showUnsaved) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import {
|
||||||
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
import type { SlideOverTab } from "./use-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 = {
|
type UseSlideOverEditStateArgs = {
|
||||||
row: WorkOrderTableRow | null;
|
row: WorkOrderTableRow | null;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
|
|
@ -53,9 +57,7 @@ export function useSlideOverEditState({
|
||||||
}
|
}
|
||||||
setDraft(row);
|
setDraft(row);
|
||||||
setBaseline(row);
|
setBaseline(row);
|
||||||
setEditing(
|
setEditing(editMode && !isSlideOverInfoLocked(row.status));
|
||||||
editMode && !isWorkOrderFullyLocked(row.status) && !isWorkOrderCoreLocked(row.status),
|
|
||||||
);
|
|
||||||
setTab("info");
|
setTab("info");
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
setUnsavedIntent(null);
|
setUnsavedIntent(null);
|
||||||
|
|
@ -63,6 +65,21 @@ export function useSlideOverEditState({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||||
}, [row?.id, editMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!infoSource) return;
|
if (!infoSource) return;
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
|
|
@ -90,6 +107,7 @@ export function useSlideOverEditState({
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (!row || !activeDraft || !dirtyBaseline) return;
|
if (!row || !activeDraft || !dirtyBaseline) return;
|
||||||
|
if (isSlideOverInfoLocked(infoSource?.status ?? row.status)) return;
|
||||||
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
|
@ -145,7 +163,7 @@ export function useSlideOverEditState({
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (!infoSource) return;
|
if (!infoSource) return;
|
||||||
if (isWorkOrderFullyLocked(infoSource.status) || isWorkOrderCoreLocked(infoSource.status)) {
|
if (isSlideOverInfoLocked(infoSource.status)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraft(infoSource);
|
setDraft(infoSource);
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ export function canMutateWorkOrderExtraDocs(status: string | null | undefined):
|
||||||
const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]);
|
const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Board PATCH policy for SH-115. Comments and Extra Docs use dedicated endpoints
|
* Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use
|
||||||
* (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`). Those requests
|
* dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`).
|
||||||
* may still fail with 422 `ReadOnly` if the backend lock remains total.
|
* Those requests may still fail with 422 `ReadOnly` if the backend lock remains total.
|
||||||
*/
|
*/
|
||||||
export function canApplyWorkOrderTablePatch(
|
export function canApplyWorkOrderTablePatch(
|
||||||
status: string | null | undefined,
|
status: string | null | undefined,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ import { describe, expect, it, vi } from "vitest";
|
||||||
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
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", () => {
|
describe("SlideOverFooter completed lock", () => {
|
||||||
it("hides Edit and Cancel WO when the work order is completed", () => {
|
it("hides Edit and Cancel WO when the work order is completed", () => {
|
||||||
const { container } = renderWithProviders(
|
const { container } = renderWithProviders(
|
||||||
|
|
@ -12,13 +22,7 @@ describe("SlideOverFooter completed lock", () => {
|
||||||
editing={false}
|
editing={false}
|
||||||
isDirty={false}
|
isDirty={false}
|
||||||
showUnsaved={false}
|
showUnsaved={false}
|
||||||
onCancelWO={vi.fn()}
|
{...footerHandlers}
|
||||||
onCancelEdit={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onEdit={vi.fn()}
|
|
||||||
onSave={vi.fn()}
|
|
||||||
onDiscard={vi.fn()}
|
|
||||||
onKeepEditing={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
@ -27,4 +31,40 @@ describe("SlideOverFooter completed lock", () => {
|
||||||
expect(screen.queryByRole("button", { name: /edit wo/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /edit wo/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/cancel 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue