fix(work-orders): accept SH placeholder numbers in the inline WO number field

This commit is contained in:
Codex Review Integration 2026-09-16 13:12:24 -03:00
parent fc3e7591dc
commit 45b988c522
4 changed files with 161 additions and 7 deletions

View file

@ -3,6 +3,12 @@ import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import {
WO_NUMBER_DOWNGRADE_MESSAGE,
WO_NUMBER_FORMAT_MESSAGE,
isValidBoardWoNumber,
isWoNumberDowngrade,
} from "@/domain/work-orders/utils/wo-number-input";
import { EditableWONumberInput } from "./editable-wo-number-input"; import { EditableWONumberInput } from "./editable-wo-number-input";
import { HighlightText } from "./highlight-text"; import { HighlightText } from "./highlight-text";
import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills"; import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills";
@ -24,6 +30,9 @@ type EditableWONumberCellProps = {
locked?: boolean; locked?: boolean;
}; };
/** Inline-create rows pass this id; their number is draft state, never a saved WO number. */
const UNSAVED_DRAFT_ID = "draft";
async function resolveDuplicateWoNumber( async function resolveDuplicateWoNumber(
draft: string, draft: string,
allOrders: WorkOrderTableRow[], allOrders: WorkOrderTableRow[],
@ -63,8 +72,14 @@ export function EditableWONumberCell({
setEditing(false); setEditing(false);
return; return;
} }
if (draft.length !== 11) { if (!isValidBoardWoNumber(draft)) {
toast("Work order number must be exactly 11 digits"); toast(WO_NUMBER_FORMAT_MESSAGE);
return;
}
if (String(currentId) !== UNSAVED_DRAFT_ID && isWoNumberDowngrade(value, draft)) {
toast.error(WO_NUMBER_DOWNGRADE_MESSAGE);
setDraft(value);
setEditing(false);
return; return;
} }
setIsChecking(true); setIsChecking(true);
@ -97,14 +112,14 @@ export function EditableWONumberCell({
const handleBlur = () => { const handleBlur = () => {
if (isChecking) return; if (isChecking) return;
if (draft !== value && draft.length === 11) { if (draft !== value && isValidBoardWoNumber(draft)) {
commit(); commit();
return; return;
} }
setEditing(false); setEditing(false);
}; };
const invalid = editing && draft.length > 0 && draft.length !== 11; const invalid = editing && draft.length > 0 && !isValidBoardWoNumber(draft);
if (locked) { if (locked) {
return ( return (

View file

@ -1,3 +1,5 @@
import { sanitizeBoardWoNumberInput } from "@/domain/work-orders/utils/wo-number-input";
type EditableWONumberInputProps = { type EditableWONumberInputProps = {
draft: string; draft: string;
invalid: boolean; invalid: boolean;
@ -21,9 +23,8 @@ export function EditableWONumberInput({
<input <input
autoFocus autoFocus
disabled={isChecking} disabled={isChecking}
inputMode="numeric"
value={draft} value={draft}
onChange={(event) => onChange(event.target.value.replace(/\D/g, "").slice(0, 11))} onChange={(event) => onChange(sanitizeBoardWoNumberInput(event.target.value))}
onFocus={(event) => event.target.select()} onFocus={(event) => event.target.select()}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { onKeyDown={(event) => {
@ -36,7 +37,7 @@ export function EditableWONumberInput({
} }
}} }}
onBlur={onBlur ?? onCommit} onBlur={onBlur ?? onCommit}
placeholder="12345678901" placeholder="e.g. 12345678901 or SH00001"
aria-label="Work order number" aria-label="Work order number"
aria-invalid={invalid} aria-invalid={invalid}
className="font-mono focus:outline-none" className="font-mono focus:outline-none"

View file

@ -0,0 +1,33 @@
import { isRealWoNumber } from "@/domain/work-orders/utils/wo-closability";
/** Board WO numbers are an 11-digit APM number or an SH placeholder of at most 11 characters. */
export const BOARD_WO_NUMBER_MAX_LENGTH = 11;
export const WO_NUMBER_FORMAT_MESSAGE =
"Work order number must be 11 digits or an SH number (e.g. SH00001)";
export const WO_NUMBER_DOWNGRADE_MESSAGE =
"Cannot replace an APM work order number with an internal number";
export function sanitizeBoardWoNumberInput(raw: string): string {
return raw
.replace(/[^a-zA-Z0-9]/g, "")
.toUpperCase()
.slice(0, BOARD_WO_NUMBER_MAX_LENGTH);
}
export function isProvisionalWoNumber(woNumber: string): boolean {
return /^SH/i.test(woNumber.trim());
}
export function isValidBoardWoNumber(woNumber: string): boolean {
return /^\d{11}$/.test(woNumber) || /^SH\d{1,9}$/i.test(woNumber);
}
/**
* SH-320: only an already-saved real APM number is protected from being replaced by an
* SH placeholder. A brand-new WO has no saved number, so the rule never applies there.
*/
export function isWoNumberDowngrade(savedWoNumber: string, nextWoNumber: string): boolean {
return isRealWoNumber(savedWoNumber) && isProvisionalWoNumber(nextWoNumber);
}

View file

@ -0,0 +1,105 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => {
const toastFn = Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() });
return { toast: toastFn };
});
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: { findByWoNumber: vi.fn() },
}));
function renderCell(value: string, currentId: string | number) {
const onSave = vi.fn();
renderWithProviders(
<EditableWONumberCell
value={value}
rescheduleCount={0}
currentId={currentId}
allOrders={[]}
onSave={onSave}
/>,
);
return onSave;
}
function typeAndCommit(nextValue: string) {
fireEvent.click(screen.getByText(/./, { selector: "span" }));
const input = screen.getByLabelText("Work order number");
fireEvent.change(input, { target: { value: nextValue } });
fireEvent.keyDown(input, { key: "Enter" });
}
describe("EditableWONumberCell SH placeholder numbers (SH-320)", () => {
beforeEach(() => {
vi.mocked(workOrdersApi.findByWoNumber).mockResolvedValue(null as never);
vi.mocked(toast).mockClear();
vi.mocked(toast.error).mockClear();
});
it("saves a freshly typed SH number on a brand-new inline work order", async () => {
const onSave = renderCell("", "draft");
typeAndCommit("SH00001");
await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00001"));
expect(toast).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it("keeps a typed SH prefix instead of stripping it to digits", () => {
renderCell("", "draft");
fireEvent.click(screen.getByText("-"));
const input = screen.getByLabelText("Work order number");
fireEvent.change(input, { target: { value: "sh00001" } });
expect(input).toHaveValue("SH00001");
});
it("does not treat an unsaved inline draft number as a real number to protect", async () => {
const onSave = renderCell("12345678901", "draft");
typeAndCommit("SH00002");
await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00002"));
});
it("blocks replacing a saved real APM number with an SH number", async () => {
const onSave = renderCell("12345678901", 42);
typeAndCommit("SH00001");
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"Cannot replace an APM work order number with an internal number",
),
);
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByText("12345678901")).toBeInTheDocument();
});
it("lets a saved SH number be replaced by a real 11-digit number", async () => {
const onSave = renderCell("SH00001", 42);
typeAndCommit("12345678901");
await waitFor(() => expect(onSave).toHaveBeenCalledWith("12345678901"));
});
it("still rejects a numeric number that is not 11 digits", () => {
const onSave = renderCell("", "draft");
typeAndCommit("12345");
expect(toast).toHaveBeenCalledWith(
"Work order number must be 11 digits or an SH number (e.g. SH00001)",
);
expect(onSave).not.toHaveBeenCalled();
});
});