From 45b988c52242012a87f4a91c41c86aa72da4136d Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:12:24 -0300 Subject: [PATCH 1/4] fix(work-orders): accept SH placeholder numbers in the inline WO number field --- .../table/cells/editable-wo-number-cell.tsx | 23 +++- .../table/cells/editable-wo-number-input.tsx | 7 +- .../work-orders/utils/wo-number-input.ts | 33 ++++++ ...ble-wo-number-cell-sh-placeholder.test.tsx | 105 ++++++++++++++++++ 4 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 src/domain/work-orders/utils/wo-number-input.ts create mode 100644 src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 369c1101..db0db0f9 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -3,6 +3,12 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; 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 { + 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 { HighlightText } from "./highlight-text"; import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills"; @@ -24,6 +30,9 @@ type EditableWONumberCellProps = { 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( draft: string, allOrders: WorkOrderTableRow[], @@ -63,8 +72,14 @@ export function EditableWONumberCell({ setEditing(false); return; } - if (draft.length !== 11) { - toast("Work order number must be exactly 11 digits"); + if (!isValidBoardWoNumber(draft)) { + 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; } setIsChecking(true); @@ -97,14 +112,14 @@ export function EditableWONumberCell({ const handleBlur = () => { if (isChecking) return; - if (draft !== value && draft.length === 11) { + if (draft !== value && isValidBoardWoNumber(draft)) { commit(); return; } setEditing(false); }; - const invalid = editing && draft.length > 0 && draft.length !== 11; + const invalid = editing && draft.length > 0 && !isValidBoardWoNumber(draft); if (locked) { return ( diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx index f4becca8..1ef6512e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx @@ -1,3 +1,5 @@ +import { sanitizeBoardWoNumberInput } from "@/domain/work-orders/utils/wo-number-input"; + type EditableWONumberInputProps = { draft: string; invalid: boolean; @@ -21,9 +23,8 @@ export function EditableWONumberInput({ onChange(event.target.value.replace(/\D/g, "").slice(0, 11))} + onChange={(event) => onChange(sanitizeBoardWoNumberInput(event.target.value))} onFocus={(event) => event.target.select()} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { @@ -36,7 +37,7 @@ export function EditableWONumberInput({ } }} onBlur={onBlur ?? onCommit} - placeholder="12345678901" + placeholder="e.g. 12345678901 or SH00001" aria-label="Work order number" aria-invalid={invalid} className="font-mono focus:outline-none" diff --git a/src/domain/work-orders/utils/wo-number-input.ts b/src/domain/work-orders/utils/wo-number-input.ts new file mode 100644 index 00000000..39aec309 --- /dev/null +++ b/src/domain/work-orders/utils/wo-number-input.ts @@ -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); +} diff --git a/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx new file mode 100644 index 00000000..5ae3cde3 --- /dev/null +++ b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx @@ -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( + , + ); + 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(); + }); +}); From 050e3f5134e218e4b63a587044d1e22647db1f67 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:13:35 -0300 Subject: [PATCH 2/4] test(work-orders): cover SH placeholder WO numbers on the board --- .../wo-number-sh-placeholder.spec.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 e2e/work-orders/wo-number-sh-placeholder.spec.ts diff --git a/e2e/work-orders/wo-number-sh-placeholder.spec.ts b/e2e/work-orders/wo-number-sh-placeholder.spec.ts new file mode 100644 index 00000000..6d14e801 --- /dev/null +++ b/e2e/work-orders/wo-number-sh-placeholder.spec.ts @@ -0,0 +1,109 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-number-sh-placeholder-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +const REAL_ROW = { + id: 1000, + woNumber: "12345678901", + workOrderType: 2, + siteCode: "DAL1", + lifecycleStatusLabel: "Scheduled", + scheduledDate: "2026-08-19", + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", +}; + +async function mockBoard(page: Page, patches: unknown[]) { + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const request = route.request(); + const url = request.url(); + if (request.method() !== "GET") { + patches.push({ url, body: request.postDataJSON() }); + await fulfillJson(route, {}, 500); + return; + } + if (url.includes("workorders/board/search")) { + await fulfillJson(route, { items: [REAL_ROW], totalCount: 1, page: 0, pageSize: 50 }); + return; + } + if (url.includes("workorders/board")) { + await fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [REAL_ROW], + }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("inline WO number SH placeholder (SH-320)", () => { + test.setTimeout(60_000); + + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + }); + + test("keeps a typed SH number on a new inline row and blocks downgrading a saved real number", async ({ + page, + }) => { + const mutations: unknown[] = []; + await mockBoard(page, mutations); + await page.goto("/workorders"); + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText("12345678901").first()).toBeVisible(); + + await page + .getByRole("button", { name: /Add WO/ }) + .first() + .click(); + const inlineRow = page.locator("tr").filter({ has: page.getByText("-", { exact: true }) }); + await inlineRow.getByText("-", { exact: true }).first().click(); + const draftInput = page.getByLabel("Work order number"); + await draftInput.fill("SH00001"); + await draftInput.press("Enter"); + await expect(draftInput).toHaveCount(0); + await expect(page.getByText("SH00001", { exact: true })).toBeVisible(); + await expect(page.getByText(/must be 11 digits/)).toHaveCount(0); + + await page.getByText("12345678901", { exact: true }).first().click(); + const savedInput = page.getByLabel("Work order number"); + await savedInput.fill("SH00009"); + await savedInput.press("Enter"); + await expect( + page.getByText("Cannot replace an APM work order number with an internal number"), + ).toBeVisible(); + await expect(page.getByText("12345678901", { exact: true }).first()).toBeVisible(); + expect(mutations).toEqual([]); + }); +}); From 4eb929e1c8586fa2c744a125b7037911f59441cd Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 15:15:52 -0300 Subject: [PATCH 3/4] test(work-orders): remove query devtools overlay after navigation Also drops ticket keys from shipped comments and test names. Co-Authored-By: Claude Opus 5 (1M context) --- .../wo-number-sh-placeholder.spec.ts | 18 ++++++++++++++---- .../work-orders/utils/wo-number-input.ts | 2 +- ...able-wo-number-cell-sh-placeholder.test.tsx | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/e2e/work-orders/wo-number-sh-placeholder.spec.ts b/e2e/work-orders/wo-number-sh-placeholder.spec.ts index 6d14e801..e8bfaf61 100644 --- a/e2e/work-orders/wo-number-sh-placeholder.spec.ts +++ b/e2e/work-orders/wo-number-sh-placeholder.spec.ts @@ -16,6 +16,18 @@ async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } +async function hideQueryDevtools(page: Page) { + await page.addStyleTag({ + content: + ".tsqd-parent-container { display: none !important; pointer-events: none !important; }", + }); + await page.locator(".tsqd-parent-container").evaluateAll((nodes) => { + for (const node of nodes) { + node.remove(); + } + }); +} + const REAL_ROW = { id: 1000, woNumber: "12345678901", @@ -58,7 +70,7 @@ async function mockBoard(page: Page, patches: unknown[]) { ); } -test.describe("inline WO number SH placeholder (SH-320)", () => { +test.describe("inline WO number SH placeholder", () => { test.setTimeout(60_000); test.beforeEach(async ({ page }) => { @@ -66,9 +78,6 @@ test.describe("inline WO number SH placeholder (SH-320)", () => { await page.clock.resume(); await page.addInitScript((user) => { localStorage.setItem("auth", JSON.stringify(user)); - const style = document.createElement("style"); - style.textContent = ".tsqd-parent-container { display: none !important; }"; - document.documentElement.appendChild(style); }, TEST_USER); }); @@ -78,6 +87,7 @@ test.describe("inline WO number SH placeholder (SH-320)", () => { const mutations: unknown[] = []; await mockBoard(page, mutations); await page.goto("/workorders"); + await hideQueryDevtools(page); await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ timeout: 20_000, }); diff --git a/src/domain/work-orders/utils/wo-number-input.ts b/src/domain/work-orders/utils/wo-number-input.ts index 39aec309..7f3ae624 100644 --- a/src/domain/work-orders/utils/wo-number-input.ts +++ b/src/domain/work-orders/utils/wo-number-input.ts @@ -25,7 +25,7 @@ export function isValidBoardWoNumber(woNumber: string): boolean { } /** - * SH-320: only an already-saved real APM number is protected from being replaced by an + * 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 { diff --git a/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx index 5ae3cde3..ad16d2e8 100644 --- a/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx +++ b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx @@ -35,7 +35,7 @@ function typeAndCommit(nextValue: string) { fireEvent.keyDown(input, { key: "Enter" }); } -describe("EditableWONumberCell SH placeholder numbers (SH-320)", () => { +describe("EditableWONumberCell SH placeholder numbers", () => { beforeEach(() => { vi.mocked(workOrdersApi.findByWoNumber).mockResolvedValue(null as never); vi.mocked(toast).mockClear(); From 49923c9a024d8339901f807c07e4718a8209e8df Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 22:06:32 -0300 Subject: [PATCH 4/4] test(work-orders): target the Insert WO # placeholder in the SH number e2e The inline create row now renders the empty WO # cell as a required "Insert WO # *" field instead of "-", so the spec waited for a cell that no longer exists. --- e2e/work-orders/wo-number-sh-placeholder.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/work-orders/wo-number-sh-placeholder.spec.ts b/e2e/work-orders/wo-number-sh-placeholder.spec.ts index e8bfaf61..f2211583 100644 --- a/e2e/work-orders/wo-number-sh-placeholder.spec.ts +++ b/e2e/work-orders/wo-number-sh-placeholder.spec.ts @@ -97,8 +97,10 @@ test.describe("inline WO number SH placeholder", () => { .getByRole("button", { name: /Add WO/ }) .first() .click(); - const inlineRow = page.locator("tr").filter({ has: page.getByText("-", { exact: true }) }); - await inlineRow.getByText("-", { exact: true }).first().click(); + await page + .getByText(/^Insert WO #/) + .first() + .click(); const draftInput = page.getByLabel("Work order number"); await draftInput.fill("SH00001"); await draftInput.press("Enter");