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..f2211583
--- /dev/null
+++ b/e2e/work-orders/wo-number-sh-placeholder.spec.ts
@@ -0,0 +1,121 @@
+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) });
+}
+
+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",
+ 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", () => {
+ 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));
+ }, 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 hideQueryDevtools(page);
+ 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();
+ await page
+ .getByText(/^Insert WO #/)
+ .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([]);
+ });
+});
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 39744598..9273a5e8 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";
@@ -26,6 +32,9 @@ type EditableWONumberCellProps = {
requiredPlaceholder?: string;
};
+/** 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[],
@@ -88,8 +97,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);
@@ -122,14 +137,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..7f3ae624
--- /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);
+}
+
+/**
+ * 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..ad16d2e8
--- /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", () => {
+ 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();
+ });
+});