Compare commits

...

9 commits

Author SHA1 Message Date
Alexandre Brandizzi
24cbd09a60
Merge pull request #284 from Sea-Haven-Industries/fix/ab/sh-357-pending-table-only-thr_9fpik7tdmw
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-357: remove Pending from existing work order status menu
2026-09-29 16:22:11 +00:00
Alexandre Brandizzi
5ace29eda4 test: expect Pending disabled on dated inline draft 2026-09-29 13:12:11 -03:00
Alexandre Brandizzi
7d5d015602 fix: require undated drafts for Pending status 2026-09-29 13:04:51 -03:00
Alexandre Brandizzi
4771f6f8eb test: cover pending schedule date promotion 2026-09-29 12:52:02 -03:00
Alexandre Brandizzi
a61ab1919a test: find pending creation in unscheduled results 2026-09-29 12:43:38 -03:00
Alexandre Brandizzi
52b682283c test: scope pending wizard selection assertion 2026-09-29 12:33:15 -03:00
Alexandre Brandizzi
123b1ec9b5 test: cover pending work order status workflows 2026-09-29 12:25:56 -03:00
Alexandre Brandizzi
c5d0429fa2 style(work-orders): format status menu expectations 2026-09-29 11:46:12 -03:00
Alexandre Brandizzi
b6d5ad8a1e fix(work-orders): hide Pending from existing status menus 2026-09-29 11:28:26 -03:00
6 changed files with 337 additions and 21 deletions

View file

@ -0,0 +1,268 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "In Progress",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
rowVersion: "rv-1",
};
type Role = "Admin" | "Dispatcher";
async function fulfillJson(route: Route, body: unknown) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockApi(
page: Page,
role: Role,
createPayloads: unknown[] = [],
searchUrls: string[] = [],
) {
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);
},
{
token: `wo-pending-${role.toLowerCase()}-e2e-token`,
expiration: "2030-01-01T00:00:00.000Z",
email: `${role.toLowerCase()}@seahavenind.com`,
userRoles: role,
phoneNumber: "5551234567",
fullname: `Work Order E2E ${role}`,
id: "1",
},
);
let row = { ...BOARD_ROW };
let createdRow: typeof BOARD_ROW | undefined;
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const request = route.request();
const { pathname } = new URL(request.url());
if (pathname.includes("/workorders/board/search")) {
searchUrls.push(request.url());
await fulfillJson(route, {
items: createdRow ? [createdRow] : [],
totalCount: createdRow ? 1 : 0,
page: 0,
pageSize: 50,
});
return;
}
if (pathname === "/api/workorders/board" && request.method() === "GET") {
await fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: createdRow ? 2 : 1, total: createdRow ? 2 : 1 },
unscheduled: createdRow ? [createdRow] : [],
scheduled: [row],
});
return;
}
if (pathname === "/api/workorders/board" && request.method() === "POST") {
const payload = request.postDataJSON() as {
lifecycleStatus: number;
scheduledDate?: string;
};
createPayloads.push(payload);
createdRow = {
...BOARD_ROW,
id: 900,
woNumber: "WO-900",
lifecycleStatusLabel: payload.lifecycleStatus === 3 ? "Scheduled" : "Pending",
scheduledDate: payload.scheduledDate ?? "",
rowVersion: "rv-created",
};
await fulfillJson(route, { data: { id: 900, internalWONumber: "WO-900" } });
return;
}
if (pathname === "/api/workorders/1/board" && request.method() === "PATCH") {
const body = request.postDataJSON() as { field: string; value: string };
row = { ...row, lifecycleStatusLabel: body.value, rowVersion: "rv-2" };
await fulfillJson(route, row);
return;
}
if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) {
await fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
return;
}
if (pathname === "/api/locations/10") {
await fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
async function expectManualTransitionWithoutPending(page: Page) {
const row = page.locator("#wo-row-1");
await expect(row.getByText("WO-501")).toBeVisible();
await row.getByRole("button", { name: "In Progress" }).click();
const menu = page.locator(".MuiPopover-paper").last();
await expect(menu.getByRole("button", { name: "Pending", exact: true })).toHaveCount(0);
await expect(menu.getByRole("button", { name: "On Site", exact: true })).toBeEnabled();
await menu.getByRole("button", { name: "On Site", exact: true }).click();
await expect(row.getByRole("button", { name: "On Site" })).toBeVisible();
}
test.describe("existing work order status menu", () => {
test.setTimeout(60_000);
test("Admin can choose a manual status but not Pending", async ({ page }) => {
await mockApi(page, "Admin");
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expectManualTransitionWithoutPending(page);
});
test("Dispatcher can choose a manual status but not Pending", async ({ page }) => {
await mockApi(page, "Dispatcher");
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expectManualTransitionWithoutPending(page);
});
test("Admin can select Pending for a new work order when Schedule On has no date", async ({
page,
}) => {
const createPayloads: unknown[] = [];
const searchUrls: string[] = [];
await mockApi(page, "Admin", createPayloads, searchUrls);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await expect(page.getByRole("button", { name: "Pick a date" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Incomplete", exact: true }).last().click();
const menu = page.locator(".MuiPopover-paper").last();
await expect(menu.getByRole("button", { name: "Pending", exact: true })).toBeEnabled();
await menu.getByRole("button", { name: "Pending", exact: true }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "Pending", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Create work order" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
await expect
.poll(() => createPayloads)
.toEqual([expect.objectContaining({ lifecycleStatus: 2 })]);
const filtersSheet = page.getByRole("dialog", { name: "Advanced Filters" });
await page.getByRole("button", { name: "Advanced Filters" }).click();
await expect(filtersSheet).toBeVisible();
await filtersSheet.getByRole("button", { name: "Date range" }).click();
await filtersSheet.getByRole("button", { name: "Unscheduled", exact: true }).click();
await filtersSheet.getByRole("button", { name: "Apply filters" }).click();
await expect
.poll(() => searchUrls.some((url) => url.includes("unscheduledOnly=true")))
.toBe(true);
const createdRow = page.locator("#wo-row-900");
await expect(createdRow.getByText("WO-900")).toBeVisible();
await expect(createdRow.getByRole("button", { name: "Pending" })).toBeVisible();
});
test("Admin gets Scheduled when adding Schedule On after selecting Pending", async ({ page }) => {
const createPayloads: unknown[] = [];
await mockApi(page, "Admin", createPayloads);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Incomplete", exact: true }).last().click();
const menu = page.locator(".MuiPopover-paper").last();
await menu.getByRole("button", { name: "Pending", exact: true }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "Pending", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Pick a date" }).click();
const dateTab = page.getByRole("button", { name: "Date", exact: true });
if (await dateTab.isVisible()) {
await dateTab.click();
}
await page.getByRole("button", { name: "20", exact: true }).click();
await expect(page.getByRole("button", { name: /Aug 20/ })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
const wizardDialog = page.getByRole("dialog");
const scheduledStatus = wizardDialog.getByRole("button", {
name: "Scheduled",
exact: true,
});
await expect(scheduledStatus).toBeVisible();
await scheduledStatus.click();
const statusMenu = page.locator(".MuiPopover-paper").last();
await expect(statusMenu.getByRole("button", { name: "Pending", exact: true })).toBeDisabled();
await statusMenu.getByRole("button", { name: "Scheduled", exact: true }).click();
await expect(scheduledStatus).toBeVisible();
await page.getByRole("button", { name: "Create work order" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
await expect
.poll(() => createPayloads)
.toEqual([expect.objectContaining({ lifecycleStatus: 3, scheduledDate: "2026-08-20" })]);
});
});

View file

@ -17,9 +17,11 @@ import {
*/
export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"];
/** Manually selectable statuses for an existing WO — Canceled is kebab-only. */
/**
* Manually selectable statuses for an existing WO. Pending is creation-only; Canceled is kebab-only.
*/
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter(
(status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status),
(status) => status !== "Pending" && !AUTOMATIC_LIFECYCLE_STATUSES.includes(status),
);
/** Inline options for an existing WO never repeat its current status. */
@ -36,6 +38,8 @@ export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
export const SCHEDULED_NEEDS_DATE_MSG =
"Set a Schedule On date before marking this work order as Scheduled.";
export const PENDING_NEEDS_NO_DATE_MSG =
"Clear the Schedule On date before marking this work order as Pending.";
export const PAST_DUE_BADGE_TOOLTIP =
"This WO missed its service window. Reschedule to unlock status updates.";
@ -130,6 +134,9 @@ export function getStatusOptionState(
status: WizardWOStatus,
closabilityGaps: string[],
): StatusOptionState {
if (status === "Pending" && hasConcreteScheduleDate(row)) {
return { disabled: true, title: PENDING_NEEDS_NO_DATE_MSG };
}
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
}

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CREATION_INLINE_STATUSES } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
const incompleteRow = {
id: "wo-1",
@ -70,21 +71,16 @@ describe("StatusCell closability gate", () => {
expect(offered).not.toContain("Scheduled");
expect(offered).not.toContain("Past Due");
expect(offered).not.toContain("Canceled");
expect(offered).not.toContain("Pending");
expect(offered).not.toContain(current);
expect(offered).toEqual(
[
"Pending",
"En Route",
"On Site",
"In Progress",
"Completed",
"Rescheduled",
"Pending Quote",
].filter((status) => status !== current),
["En Route", "On Site", "In Progress", "Completed", "Rescheduled", "Pending Quote"].filter(
(status) => status !== current,
),
);
});
it("offers every manual status, and no automatic one, from Incomplete or Scheduled", () => {
it("omits Pending and automatic statuses from Incomplete or Scheduled", () => {
for (const current of ["Incomplete", "Scheduled"] as const) {
const { unmount } = render(
<StatusCell
@ -95,7 +91,7 @@ describe("StatusCell closability gate", () => {
);
fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") }));
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /^pending$/i })).toBeNull();
expect(screen.queryByRole("button", { name: /^incomplete$/i })).toBeNull();
expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull();
unmount();
@ -116,4 +112,23 @@ describe("StatusCell closability gate", () => {
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
});
it("disables Pending for inline creation when Schedule On has a concrete date", () => {
render(
<StatusCell
wo={{ ...incompleteRow, status: "Scheduled", scheduledOn: "2026-06-01" }}
statusOptions={CREATION_INLINE_STATUSES}
onChangeStatus={vi.fn()}
onOpenSlideOver={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const pending = screen.getByRole("button", { name: /^pending$/i });
expect(pending).toBeDisabled();
expect(pending).toHaveAttribute(
"title",
"Clear the Schedule On date before marking this work order as Pending.",
);
});
});

View file

@ -77,7 +77,7 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => {
expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
});
it("promotes Pending to Scheduled when picked on a dated draft", () => {
it("disables Pending when Schedule On has a concrete date", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
@ -95,7 +95,13 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => {
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
const pending = screen.getByRole("button", { name: /^pending$/i });
expect(pending).toBeDisabled();
expect(pending).toHaveAttribute(
"title",
"Clear the Schedule On date before marking this work order as Pending.",
);
fireEvent.click(pending);
expect(onPatch).not.toHaveBeenCalled();
});
});

View file

@ -36,7 +36,7 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cel
}));
describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
it("keeps Scheduled when Incomplete is picked on a dated draft", () => {
it("disables Pending on a dated draft", () => {
const onChange = vi.fn();
const draft = {
...EMPTY_TABLE_DRAFT,
@ -66,8 +66,10 @@ describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
const pending = screen.getByRole("button", { name: /^pending$/i });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ status: "Scheduled" }));
expect(pending).toBeDisabled();
fireEvent.click(pending);
expect(onChange).not.toHaveBeenCalledWith(expect.objectContaining({ status: "Pending" }));
});
});

View file

@ -6,6 +6,7 @@ import {
COMPLETED_DOC_BLOCK_MSG,
COMPLETED_MEDIA_BLOCK_MSG,
CREATION_INLINE_STATUSES,
PENDING_NEEDS_NO_DATE_MSG,
SCHEDULED_NEEDS_DATE_MSG,
formatAapCompletedBlockTitle,
getInlineStatusTransitions,
@ -21,7 +22,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
describe("wo-lifecycle-transitions", () => {
it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => {
expect(BOARD_INLINE_STATUSES).toEqual([
"Pending",
"En Route",
"On Site",
"In Progress",
@ -37,7 +37,6 @@ describe("wo-lifecycle-transitions", () => {
it("never offers the current status as a transition (SH-358)", () => {
expect(getInlineStatusTransitions("On Site")).toEqual([
"Pending",
"En Route",
"In Progress",
"Completed",
@ -67,6 +66,25 @@ describe("wo-lifecycle-transitions", () => {
});
});
it("disables Pending only when a concrete schedule date exists", () => {
expect(getStatusOptionState({ scheduledOn: "2026-06-01" }, "Pending", [])).toEqual({
disabled: true,
title: PENDING_NEEDS_NO_DATE_MSG,
});
expect(
getStatusOptionState({ scheduledOn: "", scheduleWeekOnly: false }, "Pending", []),
).toEqual({
disabled: false,
});
expect(
getStatusOptionState(
{ scheduledOn: "2026-06-01", scheduleWeekOnly: true, schedMode: "week" },
"Pending",
[],
),
).toEqual({ disabled: false });
});
it("promotes Incomplete/Pending when a concrete date exists", () => {
expect(
maybeAutoSchedulePatch({