fix(work-orders): persist type via PATCH and lock completed rows

This commit is contained in:
Arthur Bassi 2026-09-07 11:39:12 -03:00
parent f51f6bd493
commit ccdad96170
8 changed files with 205 additions and 28 deletions

View file

@ -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 ? (
<DispatcherPill user={selected} />
) : (
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
<span className="flex min-w-0 items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
<Users
size={13}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
Select dispatcher
<span className="truncate whitespace-nowrap">Select dispatcher</span>
</span>
)}
<HoverChevron />

View file

@ -18,14 +18,32 @@ type TypeCellProps = {
value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string;
viewOnly?: boolean;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
if (value) {
return <WoTableTypeLabel type={value} q={q} />;
}
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
}
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
const [resetSeverity, setResetSeverity] = useState(false);
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
if (viewOnly) {
return (
<div className="flex w-full items-center gap-1">
<TypeCellValue value={value} q={q} />
{Boolean(showSeverityBadge && severity != null) && <SeverityBadge severity={severity!} />}
</div>
);
}
function pick(t: WOType) {
setAnchorEl(null);
@ -43,8 +61,6 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
onSave(t, needsSeverity ? severity : null);
}
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
return (
<>
<button
@ -57,11 +73,7 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
>
{value ? (
<WoTableTypeLabel type={value} q={q} />
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>
)}
<TypeCellValue value={value} q={q} />
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge
severity={severity!}

View file

@ -1,20 +1,33 @@
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 {
WorkOrderTablePatch,
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";
type PersistPatchFn = (
id: string | number,
patch: WorkOrderTablePatch,
options?: { onSuccess?: () => void },
) => void;
export function saveExistingRowType(
row: Pick<WorkOrderTableRow, "woNumber" | "severity">,
onPatch: PersistPatchFn,
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
type: WOType,
severity: WorkOrderSeverity | null,
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void,
) {
if (shouldAnnounceSeverityEscalation(row.severity, severity)) {
toast.warn(formatSeverityEscalationToast(row.woNumber, severity));
const patch = { type, severity };
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
onPatch(row.id, patch);
return;
}
onPatchRow({ type, severity });
onPatch(row.id, patch, {
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, 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 { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
import { saveExistingRowType } from "./save-existing-row-type";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
@ -104,7 +105,8 @@ export function WoTableRowCells({
value={row.type}
severity={row.severity}
q={search}
onSave={(t, sev) => saveExistingRowType(row, t, sev, onPatchRow)}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
/>
</td>
<td

View file

@ -8,35 +8,47 @@ vi.mock("react-toastify", () => ({
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
const row = { id: 7, woNumber: "00000024965", severity: 3 as const };
describe("saveExistingRowType", () => {
beforeEach(() => {
toastWarn.mockReset();
});
it("warns when severity newly enters SEV 1 and still patches the row", () => {
const onPatchRow = vi.fn();
it("warns when severity newly enters SEV 1 only after persist onSuccess", () => {
const onPatch = vi.fn();
saveExistingRowType({ woNumber: "00000024965", severity: 3 }, "Emergency", 1, onPatchRow);
saveExistingRowType(onPatch, row, "Emergency", 1);
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(
7,
{ type: "Emergency", severity: 1 },
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
const { onSuccess } = onPatch.mock.calls[0]![2] as { onSuccess: () => void };
onSuccess();
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();
const onPatch = vi.fn();
saveExistingRowType({ woNumber: "00000024965", severity: 1 }, "Emergency", 1, onPatchRow);
saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1);
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatchRow).toHaveBeenCalledWith({ type: "Emergency", severity: 1 });
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 });
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
});
it("does not warn when clearing severity on a PM correction", () => {
const onPatchRow = vi.fn();
const onPatch = vi.fn();
saveExistingRowType({ woNumber: "00000024965", severity: 2 }, "PM", null, onPatchRow);
saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null);
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatchRow).toHaveBeenCalledWith({ type: "PM", severity: null });
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
});
});

View file

@ -83,6 +83,23 @@ describe("TypeCell", () => {
expect(onSave).toHaveBeenCalledWith("Reactive", 4);
});
it("does not open the picker or severity modal when viewOnly", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Emergency" severity={2} viewOnly onSave={onSave} />);
expect(screen.queryByRole("button", { name: "Type of WO, Emergency" })).not.toBeInTheDocument();
expect(screen.getByText("Emergency")).toBeInTheDocument();
expect(screen.getByText("SEV 2")).toBeInTheDocument();
await user.click(screen.getByText("Emergency"));
await user.click(screen.getByText("SEV 2"));
expect(screen.queryByRole("button", { name: /^Reactive$/ })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it("opens the modal from the severity badge", async () => {
const onSave = vi.fn();
const user = userEvent.setup();

View file

@ -0,0 +1,69 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
SiteCell: () => null,
}));
vi.mock(
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
() => ({
EditableWONumberCell: () => null,
}),
);
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
TypeCell: ({ onSave }: { onSave: (type: string, severity: number) => void }) => (
<button type="button" onClick={() => onSave("Emergency", 1)}>
persist-type
</button>
),
}));
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(
<table>
<tbody>
<tr>
<WoTableRowCells
row={row}
allRows={[]}
users={[]}
sites={[]}
handlers={handlers}
onPatchRow={onPatchRow}
/>
</tr>
</tbody>
</table>,
{ 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) }),
);
});
});

View file

@ -133,4 +133,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(
<table>
<tbody>
<WoTableRow
row={row}
allRows={[row]}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["HVAC"]}
isSelected={false}
isFlashing={false}
dragViz={{ dragId: null, overId: null }}
handlers={handlers}
/>
</tbody>
</table>,
{ 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(
<table>
<tbody>
<WoTableRow
row={row}
allRows={[row]}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["HVAC"]}
isSelected={false}
isFlashing={false}
dragViz={{ dragId: null, overId: null }}
handlers={handlers}
/>
</tbody>
</table>,
{ withAuth: false },
);
expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument();
expect(screen.getByText("PM")).toBeInTheDocument();
});
});