mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 10:02:04 +00:00
Merge pull request #206 from Sea-Haven-Industries/fix/ab/sh-320-inline-sh-prefix-wo-number
SH-320: accept SH-prefixed placeholder work-order numbers
This commit is contained in:
commit
56a7895950
5 changed files with 282 additions and 7 deletions
121
e2e/work-orders/wo-number-sh-placeholder.spec.ts
Normal file
121
e2e/work-orders/wo-number-sh-placeholder.spec.ts
Normal file
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -26,6 +32,9 @@ type EditableWONumberCellProps = {
|
||||||
requiredPlaceholder?: string;
|
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(
|
async function resolveDuplicateWoNumber(
|
||||||
draft: string,
|
draft: string,
|
||||||
allOrders: WorkOrderTableRow[],
|
allOrders: WorkOrderTableRow[],
|
||||||
|
|
@ -88,8 +97,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);
|
||||||
|
|
@ -122,14 +137,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 (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
33
src/domain/work-orders/utils/wo-number-input.ts
Normal file
33
src/domain/work-orders/utils/wo-number-input.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
@ -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", () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue