feat(work-orders): align inline type correction with the board AAP

Keep type correction on the board: Type & severity, SEV toast, and audit copy.
This commit is contained in:
Arthur Bassi 2026-09-03 16:10:59 -03:00
parent c70587941c
commit 27ddeb2903
14 changed files with 425 additions and 3 deletions

View file

@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) {
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
>
Severity (from APM)
<Text as="span" variant="label" tone="error">
{" "}
*
</Text>
</Text>
<Text
as="p"

View file

@ -49,6 +49,8 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
<>
<button
type="button"
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
title={value ? undefined : "Select type of WO"}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
@ -58,7 +60,7 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
{value ? (
<WoTableTypeLabel type={value} q={q} />
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
<span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>
)}
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge

View file

@ -1,6 +1,14 @@
import { useEffect, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import {
@ -76,6 +84,10 @@ export function TypeSeverityModal({
>
<DialogTitle
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingRight: 1,
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 600,
@ -83,6 +95,9 @@ export function TypeSeverityModal({
}}
>
Type &amp; severity
<IconButton aria-label="Close" onClick={() => onOpenChange(false)} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<div className="space-y-3">
@ -93,6 +108,10 @@ export function TypeSeverityModal({
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
>
Type
<Text as="span" variant="label" tone="error">
{" "}
*
</Text>
</Text>
<div className="flex flex-wrap gap-1.5">
{WO_TYPES.map((t) => (

View file

@ -0,0 +1,20 @@
import { toast } from "react-toastify";
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import {
formatSeverityEscalationToast,
shouldAnnounceSeverityEscalation,
} from "@/domain/work-orders/utils/severity-escalation";
export function saveExistingRowType(
row: Pick<WorkOrderTableRow, "woNumber" | "severity">,
type: WOType,
severity: WorkOrderSeverity | null,
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void,
) {
if (shouldAnnounceSeverityEscalation(row.severity, severity)) {
toast.warn(formatSeverityEscalationToast(row.woNumber, severity));
}
onPatchRow({ type, severity });
}

View file

@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { saveExistingRowType } from "./save-existing-row-type";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
@ -100,7 +101,7 @@ export function WoTableRowCells({
value={row.type}
severity={row.severity}
q={search}
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
onSave={(t, sev) => saveExistingRowType(row, t, sev, onPatchRow)}
/>
</td>
<td

View file

@ -1,3 +1,4 @@
import { SEVERITY_LEVELS } from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
export type AuditActionSource = Pick<
@ -26,6 +27,38 @@ export type AuditActionRuleContext = {
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
function formatSeverityAuditLabel(raw: string): string {
const n = Number(raw.trim());
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
return SEVERITY_LEVELS[n].label;
}
const sevMatch = raw.trim().match(/^sev\s*([1-5])$/i);
if (sevMatch) {
return `SEV ${sevMatch[1]}`;
}
return raw;
}
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
const matches =
fieldKey === "type" ||
fieldKey === "workordertype" ||
fieldKey === "wotype" ||
actionKey === "typechanged" ||
actionKey.includes("workordertype");
if (!matches || !newValue.text) return null;
return `Changed Type to ${wrapAuditValue(newValue)}`;
}
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
if (!matches || !newValue.text) return null;
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
return `Changed Severity to ${wrapAuditValue(labeled)}`;
}
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
const matches =
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
@ -119,6 +152,8 @@ function tryAssignedToRule({
/** Applied in order; the first non-null result wins. */
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
tryStatusRule,
tryTypeRule,
trySeverityRule,
tryApptRule,
tryScheduleRule,
tryCarriedOverRule,

View file

@ -0,0 +1,20 @@
import {
SEVERITY_LEVELS,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
function isCriticalSeverity(severity: WorkOrderSeverity | null | undefined): severity is 1 | 2 {
return severity === 1 || severity === 2;
}
/** AAP: toast only when severity newly enters SEV 1/2 — not on re-save of the same value. */
export function shouldAnnounceSeverityEscalation(
previous: WorkOrderSeverity | null | undefined,
next: WorkOrderSeverity | null | undefined,
): next is 1 | 2 {
return isCriticalSeverity(next) && !isCriticalSeverity(previous);
}
export function formatSeverityEscalationToast(woNumber: string, severity: 1 | 2): string {
return `WO #${woNumber} escalated to ${SEVERITY_LEVELS[severity].label}.`;
}

View file

@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const toastWarn = vi.fn();
vi.mock("react-toastify", () => ({
toast: { warn: (...args: unknown[]) => toastWarn(...args) },
}));
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
describe("saveExistingRowType", () => {
beforeEach(() => {
toastWarn.mockReset();
});
it("warns when severity newly enters SEV 1 and still patches the row", () => {
const onPatchRow = vi.fn();
saveExistingRowType({ woNumber: "00000024965", severity: 3 }, "Emergency", 1, onPatchRow);
expect(toastWarn).toHaveBeenCalledWith("WO #00000024965 escalated to SEV 1.");
expect(onPatchRow).toHaveBeenCalledWith({ type: "Emergency", severity: 1 });
});
it("does not warn when staying on a critical severity", () => {
const onPatchRow = vi.fn();
saveExistingRowType({ woNumber: "00000024965", severity: 1 }, "Emergency", 1, onPatchRow);
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatchRow).toHaveBeenCalledWith({ type: "Emergency", severity: 1 });
});
it("does not warn when clearing severity on a PM correction", () => {
const onPatchRow = vi.fn();
saveExistingRowType({ woNumber: "00000024965", severity: 2 }, "PM", null, onPatchRow);
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatchRow).toHaveBeenCalledWith({ type: "PM", severity: null });
});
});

View file

@ -0,0 +1,96 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
describe("TypeCell", () => {
it("shows the AAP empty placeholder", () => {
render(<TypeCell value="" onSave={vi.fn()} />);
expect(screen.getByRole("button", { name: "Select type of WO" })).toBeInTheDocument();
});
it("lists PM, Reactive, Emergency, and Overdue in the popover", async () => {
const user = userEvent.setup();
render(<TypeCell value="PM" onSave={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
expect(screen.getByRole("button", { name: /^Reactive$/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Emergency$/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Overdue$/ })).toBeInTheDocument();
});
it("saves PM from the popover without opening the modal", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Emergency" severity={2} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, Emergency" }));
await user.click(screen.getByRole("button", { name: /^PM$/ }));
expect(onSave).toHaveBeenCalledWith("PM", null);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens Type & severity when changing PM to Emergency and saves after SEV", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="PM" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
await user.click(screen.getByRole("button", { name: /^Emergency$/ }));
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: /Type & severity/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /SEV 1/ }));
await user.click(screen.getByRole("button", { name: /^Save$/ }));
expect(onSave).toHaveBeenCalledWith("Emergency", 1);
});
it("does not save when Cancel is clicked on the modal", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="PM" onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
await user.click(screen.getByRole("button", { name: /^Emergency$/ }));
await user.click(screen.getByRole("button", { name: /^Cancel$/ }));
expect(onSave).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("clears previous severity when switching Emergency to Reactive", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Emergency" severity={3} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, Emergency" }));
await user.click(screen.getByRole("button", { name: /^Reactive$/ }));
expect(onSave).not.toHaveBeenCalled();
const save = screen.getByRole("button", { name: /^Save$/ });
expect(save).toBeDisabled();
await user.click(screen.getByRole("button", { name: /SEV 4/ }));
await user.click(save);
expect(onSave).toHaveBeenCalledWith("Reactive", 4);
});
it("opens the modal from the severity badge", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Emergency" severity={2} onSave={onSave} />);
await user.click(screen.getByText("SEV 2"));
expect(screen.getByRole("dialog", { name: /Type & severity/i })).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -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,
@ -642,3 +643,110 @@ 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));
});
});

View file

@ -68,6 +68,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({

View file

@ -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.",
);
});
});

View file

@ -172,6 +172,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<WOType>(["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" });

View file

@ -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);
});
});