diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index cc4cf518..bd0db367 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig e.stopPropagation(); setAnchorEl(e.currentTarget); }} - className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80" + className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80" style={ highlight ? { @@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig {selected ? ( ) : ( - + - Select dispatcher + Select dispatcher )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx index d78961af..656b5baf 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx @@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) { sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }} > Severity (from APM) + + {" "} + * + void; }; -export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) { +function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) { + if (value) { + return ; + } + return Select type of WO; +} + +export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [modalSeed, setModalSeed] = useState(value); const [resetSeverity, setResetSeverity] = useState(false); + const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null); + + if (viewOnly) { + return ( +
+ + {Boolean(showSeverityBadge && severity != null) && } +
+ ); + } function pick(t: WOType) { setAnchorEl(null); @@ -43,23 +61,19 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) { onSave(t, needsSeverity ? severity : null); } - const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null); - return ( <> + ), +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({ + DueDateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); + +const row = { id: 7, woNumber: "00000024965", severity: 3 } as WorkOrderTableRow; + +describe("WoTableRowCells type persist", () => { + it("forwards type save through patch onSuccess instead of onPatchRow", () => { + const onPatch = vi.fn(); + const onPatchRow = vi.fn(); + const handlers = { onPatch } as unknown as WoTableRowHandlers; + + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "persist-type" })); + + expect(onPatchRow).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith( + 7, + { type: "Emergency", severity: 1 }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx index 3bbed85f..1ce5a477 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -134,4 +134,56 @@ describe("WoTableRow completed selective lock", () => { expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument(); expect(handlers.onPatch).not.toHaveBeenCalled(); }); + + it("does not expose the type picker on a Completed row", () => { + const row = makeRow(); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument(); + expect(screen.getByText("PM")).toBeInTheDocument(); + }); + + it("does not expose the type picker on a Canceled row", () => { + const row = makeRow({ status: "Canceled" }); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument(); + expect(screen.getByText("PM")).toBeInTheDocument(); + }); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 514f8991..d3e1095d 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -23,6 +23,7 @@ vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, })); +import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; import { applySchedulePatch, @@ -692,6 +693,113 @@ describe("useWorkOrderTableMutations apptTime clear", () => { }); }); +describe("useWorkOrderTableMutations type and severity", () => { + let rowState: WorkOrderTableRow; + + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + rowState = { ...BASE_ROW, type: "PM", severity: null }; + }); + + it("patches severity before workOrderType and merges the API row", async () => { + const fields: string[] = []; + patchBoardField.mockImplementation( + async ( + _id: unknown, + payload: { field: string; value?: string | null; workOrderVersion: string }, + ) => { + fields.push(payload.field); + if (payload.field === "severity") { + rowState = { + ...rowState, + severity: payload.value ? (Number(payload.value) as 1) : null, + rowVersion: `${payload.workOrderVersion}-sev`, + }; + } + if (payload.field === "workOrderType") { + rowState = { + ...rowState, + type: (payload.value as WorkOrderTableRow["type"]) || rowState.type, + rowVersion: `${payload.workOrderVersion}-type`, + }; + } + return { ...rowState }; + }, + ); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { type: "Emergency", severity: 1 }); + }); + + await waitFor(() => expect(fields).toEqual(["severity", "workOrderType"])); + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ type: "Emergency", severity: 1 }), + ); + }); + + it("applies currentState on 409 conflict instead of clearing", async () => { + const currentState = { ...BASE_ROW, type: "PM" as const, severity: null }; + patchBoardField.mockRejectedValue(new BoardPatchConflictError("stale", currentState)); + + const onPatch = vi.fn(); + const clearPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { type: "Emergency", severity: 1 }); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith(42, expect.objectContaining({ type: "PM" })), + ); + expect(clearPatch).not.toHaveBeenCalled(); + }); + + it("rolls back with clearPatch on a generic type patch error", async () => { + patchBoardField.mockRejectedValue(new Error("network")); + + const onPatch = vi.fn(); + const clearPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { type: "Emergency", severity: 1 }); + }); + + await waitFor(() => expect(clearPatch).toHaveBeenCalledWith(42)); + }); +}); + describe("useWorkOrderTableMutations audit cache refresh", () => { it("invalidates work-order queries after a successful status transition", async () => { const client = new QueryClient({ diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index 3ba0ad1b..8c9ff415 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -89,6 +89,28 @@ describe("formatBoardAuditAction", () => { ).toBe("WO marked as Past Due. Schedule On has passed without completion."); }); + it("formats type correction as Changed Type to (AAP, new value only)", () => { + expect( + formatBoardAuditAction({ + action: "FieldPatched", + fieldName: "WorkOrderType", + oldValue: "PM", + newValue: "Emergency", + }), + ).toBe('Changed Type to "Emergency"'); + }); + + it("formats severity as Changed Severity to SEV N", () => { + expect( + formatBoardAuditAction({ + action: "FieldPatched", + fieldName: "Severity", + oldValue: "3", + newValue: "1", + }), + ).toBe('Changed Severity to "SEV 1"'); + }); + it("falls back to from → to for unknown fields", () => { expect( formatBoardAuditAction({ diff --git a/src/test/domain/work-orders/utils/severity-escalation.test.ts b/src/test/domain/work-orders/utils/severity-escalation.test.ts new file mode 100644 index 00000000..812e40f9 --- /dev/null +++ b/src/test/domain/work-orders/utils/severity-escalation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + formatSeverityEscalationToast, + shouldAnnounceSeverityEscalation, +} from "@/domain/work-orders/utils/severity-escalation"; + +describe("shouldAnnounceSeverityEscalation", () => { + it("announces when severity newly enters SEV 1", () => { + expect(shouldAnnounceSeverityEscalation(3, 1)).toBe(true); + expect(shouldAnnounceSeverityEscalation(null, 1)).toBe(true); + }); + + it("does not announce a re-save of the same critical severity", () => { + expect(shouldAnnounceSeverityEscalation(1, 1)).toBe(false); + }); + + it("does not announce a de-escalation out of SEV 1/2", () => { + expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false); + }); + + it("does not announce SEV 2 to SEV 1 (already critical)", () => { + expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false); + }); +}); + +describe("formatSeverityEscalationToast", () => { + it("matches the AAP warning copy", () => { + expect(formatSeverityEscalationToast("00000024965", 1)).toBe( + "WO #00000024965 escalated to SEV 1.", + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 85651d2c..b6a6c416 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -194,6 +194,22 @@ describe("filterByBarAndSearch", () => { expect(ids).toEqual(["pd"]); }); + it("hides a row after type correction when the bar filter no longer matches", () => { + const afterCorrection = makeRow({ + id: "1", + type: "Emergency", + scheduledOn: "2026-06-03", + }); + + expect( + filterByBarAndSearch( + [afterCorrection], + bar(weekMonday, { types: new Set(["PM"]) }), + "", + ).map((row) => row.id), + ).toEqual([]); + }); + it("does not filter by type when the set is empty", () => { const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" }); const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" }); diff --git a/src/test/domain/work-orders/utils/work-order-lock.test.ts b/src/test/domain/work-orders/utils/work-order-lock.test.ts index f65f3a30..e8ce1f1b 100644 --- a/src/test/domain/work-orders/utils/work-order-lock.test.ts +++ b/src/test/domain/work-orders/utils/work-order-lock.test.ts @@ -45,5 +45,10 @@ describe("work-order-lock", () => { expect(canApplyWorkOrderTablePatch("Completed", { site: "BK1" })).toBe(false); expect(canApplyWorkOrderTablePatch("Scheduled", { dispatcherId: "u2" })).toBe(true); expect(canApplyWorkOrderTablePatch("Canceled", { flagColor: "red" })).toBe(false); + expect(canApplyWorkOrderTablePatch("Completed", { type: "Emergency", severity: 1 })).toBe( + false, + ); + expect(canApplyWorkOrderTablePatch("Canceled", { type: "PM" })).toBe(false); + expect(canApplyWorkOrderTablePatch("Scheduled", { type: "Reactive", severity: 3 })).toBe(true); }); });