shoc-frontend-new/e2e/work-orders/inline-technician.spec.ts

489 lines
17 KiB
TypeScript

import { expect, test, type Page, type Request, type Route } from "@playwright/test";
// Technicians registered inline from work-order entry points
// become real vendor records only on save, through the additive vendor-company roster
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-inline-technician-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const VENDORS = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
},
];
const COASTAL_VENDOR = {
id: 50,
companyName: "Coastal HVAC",
contactName: "Avery Chen",
tradeSpecialties: "HVAC",
address: "9 Shore Rd",
};
const ROSTER = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
vendorId: 41,
vendorName: "Vinewood LLC",
techName: "Adam Whyte",
technicianAssigned: true,
completedDate: "2026-08-19",
docStatus: "Yes",
mediaCount: 1,
title: "Quarterly HVAC PM",
rowVersion: "rv-1",
};
type Captured = { method: string; pathname: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function bodyOf(request: Request): unknown {
try {
return request.postDataJSON();
} catch {
return undefined;
}
}
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
const added = (body.addTechnicians ?? []).map((technician, index) => ({
...technician,
id: 42 + index,
}));
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(
page: Page,
writes: Captured[],
initialRows: Array<Record<string, unknown>> = [BOARD_ROW],
) {
const currentBoardRows = [...initialRows];
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);
await page.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
const method = request.method();
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
if (pathname.includes("/workorders/board/search")) {
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
}
if (pathname === "/api/workorders/board" && method === "GET") {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: currentBoardRows.length, total: currentBoardRows.length },
unscheduled: [],
scheduled: currentBoardRows,
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (/^\/api\/workorders\/\d+\/board$/u.test(pathname) && method === "PATCH") {
const body = bodyOf(request) as {
field?: string;
value?: string;
technicianAssigned?: boolean;
};
const id = Number(pathname.split("/")[3]);
const index = currentBoardRows.findIndex((row) => row.id === id);
if (index < 0) return fulfillJson(route, { message: "Missing fixture row" }, 404);
const current = currentBoardRows[index];
if (current == null) return fulfillJson(route, { message: "Missing fixture row" }, 404);
if (body.field === "vendorId") {
const vendorId = Number(body.value);
currentBoardRows[index] = {
...current,
vendorId,
vendorName: vendorId === 50 ? "Coastal HVAC" : "Vinewood LLC",
...(body.technicianAssigned === false
? { technicianAssigned: false, techName: null, techPhone: null }
: body.technicianAssigned === true
? {
technicianAssigned: true,
techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte",
}
: {}),
rowVersion: "rv-2",
};
} else if (body.field === "lifecycleStatus") {
currentBoardRows[index] = {
...current,
lifecycleStatusLabel: body.value,
...(body.value === "Completed"
? {
frozenCompany: {
name: current.vendorName,
contactName: current.vendorName === "Coastal HVAC" ? "Avery Chen" : "Adam Whyte",
phone: "555-0100",
technicianAssigned: current.technicianAssigned,
},
}
: {}),
rowVersion: "rv-3",
};
}
return fulfillJson(route, currentBoardRows[index]);
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
}
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: [...VENDORS, COASTAL_VENDOR] });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
}
if (pathname === "/api/locations/10") {
return fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
}
return fulfillJson(route, { data: [] });
});
}
function vendorWrites(writes: Captured[]) {
return writes.filter(
(write) =>
write.pathname.startsWith("/api/vendors") ||
write.pathname.startsWith("/api/vendor-company-roster"),
);
}
async function openWizardVendorStep(page: Page) {
await page.goto("/workorders");
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 expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select company/i }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await page.getByRole("button", { name: "+ Add technician" }).click();
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
await page.getByLabel("New Technician 1 phone").fill("3145550199");
}
test.describe("Inline technician registration", () => {
test("wizard persists the technician under the company on create and assigns it", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
// Staging a technician is local only.
expect(vendorWrites(writes)).toEqual([]);
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
.toBeTruthy();
expect(vendorWrites(writes)).toEqual([
{
method: "PATCH",
pathname: "/api/vendor-company-roster/7",
body: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
},
},
]);
const create = writes.find((write) => write.pathname === "/api/workorders/board");
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("company-only save returns no technician and stays empty after reopen and reload", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
const row = page.locator("#wo-row-1");
await row.getByRole("button", { name: /Adam Whyte/ }).click();
const dialog = page.getByRole("dialog");
await dialog
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.click();
await dialog.getByRole("button", { name: "Coastal HVAC" }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(boardPatch?.body).toEqual(
expect.objectContaining({ field: "vendorId", value: "50", technicianAssigned: false }),
);
await expect(row.getByRole("button", { name: /Coastal HVAC/ })).toBeVisible();
await row.getByRole("button", { name: /Coastal HVAC/ }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
).toBeVisible();
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
.click();
await page.reload();
const reloadedRow = page.locator("#wo-row-1");
await expect(reloadedRow.getByRole("button", { name: /Coastal HVAC/ })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Coastal HVAC/ }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
).toBeVisible();
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
.click();
await reloadedRow.getByRole("button", { name: /in progress/i }).click();
await page.getByRole("button", { name: "Completed" }).click();
await page
.getByRole("dialog", { name: "Mark as Complete?" })
.getByRole("button", { name: "Mark as Complete" })
.click();
await expect
.poll(() =>
writes.some(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string }).field === "lifecycleStatus",
),
)
.toBe(true);
await expect(reloadedRow.getByRole("button", { name: /completed/i })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Coastal HVAC/ }).click();
const completedDialog = page.getByRole("dialog");
await expect(completedDialog.getByText("—", { exact: true })).toBeVisible();
await expect(completedDialog.getByRole("button", { name: /^save$/i })).toHaveCount(0);
expect(
writes.some(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string }).field === "lifecycleStatus",
),
).toBe(true);
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
});
test("company absence blocks completion for PM, Emergency, Reactive, and Overdue", async ({
page,
}) => {
const writes: Captured[] = [];
const withoutCompany = [
[1, 2],
[2, 3],
[3, 6],
[4, 8],
].map(([id, workOrderType]) => ({
...BOARD_ROW,
id,
woNumber: `WO-${600 + id}`,
workOrderType,
lifecycleStatusLabel: "In Progress",
vendorId: null,
vendorName: null,
techName: "Avery Chen",
technicianAssigned: true,
docStatus: "Yes",
mediaCount: 1,
}));
await mockApi(page, writes, withoutCompany);
await page.goto("/workorders");
for (const id of [1, 2, 3, 4]) {
const row = page.locator(`#wo-row-${id}`);
await row.getByRole("button", { name: /in progress/i }).click();
const completed = page.getByRole("button", { name: "Completed" });
await expect(completed).toBeDisabled();
await expect(completed).toHaveAttribute(
"title",
"Fill in Vendor before completing this work order.",
);
await page.keyboard.press("Escape");
}
expect(writes.filter((write) => write.pathname.endsWith("/board"))).toEqual([]);
});
test("explicit technician choice is retained and can complete after reload", async ({ page }) => {
const writes: Captured[] = [];
const unassigned = {
...BOARD_ROW,
technicianAssigned: false,
techName: null,
techPhone: null,
};
await mockApi(page, writes, [unassigned]);
await page.goto("/workorders");
const row = page.locator("#wo-row-1");
await row.getByRole("button", { name: /Vinewood LLC/ }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "No technician" }).click();
await dialog.getByRole("button", { name: "Adam Whyte" }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
const assignmentWrite = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(assignmentWrite?.body).toEqual(
expect.objectContaining({ field: "vendorId", value: "41", technicianAssigned: true }),
);
await expect(row.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
await page.reload();
const reloadedRow = page.locator("#wo-row-1");
await expect(reloadedRow.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Adam Whyte/ }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "Adam Whyte" }),
).toBeVisible();
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
.click();
await reloadedRow.getByRole("button", { name: /in progress/i }).click();
await page.getByRole("button", { name: "Completed" }).click();
await page
.getByRole("dialog", { name: "Mark as Complete?" })
.getByRole("button", { name: "Mark as Complete" })
.click();
await expect
.poll(() => writes.filter((write) => write.pathname === "/api/workorders/1/board").length)
.toBe(2);
await expect(reloadedRow.getByRole("button", { name: /completed/i })).toBeVisible();
expect(
writes.some(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string; value?: string }).field === "lifecycleStatus",
),
).toBe(true);
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
// Reopening starts clean: the staged technician was discarded, not persisted.
await page.getByRole("button", { name: "New WO" }).click();
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 expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
expect(writes).toEqual([]);
});
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
await page
.locator("#wo-row-1")
.getByRole("button", { name: /Adam Whyte/ })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
await dialog.getByRole("button", { name: /^add$/i }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
.toBeTruthy();
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(JSON.stringify(boardPatch?.body)).toContain("42");
});
});