import { expect, test, type Page, type Route } from "@playwright/test"; const TEST_USER = { token: "vendor-e2e-token", expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), email: "admin@seahavenind.com", userRoles: "Admin", phoneNumber: "5551234567", fullname: "Vendor E2E Admin", id: "1", }; const activeVendors = [ { Id: 1, CompanyId: 101, CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", Email: "dispatch@gateway.test", Phone: "314-555-0198", CompanyPhone: "314-555-0100", Address: "1 Market St", City: "St. Louis", State: "MO", Zip: "63101", TradeSpecialties: "Plumbing, Backflow Preventers", GoogleMapsUrl: "https://maps.google.com/gateway", Notes: "Preferred after-hours vendor", TotalJobs: 72, IsActive: true, PreferredContact: "Email", AreaId: 1 as number | null, AreaName: "East", }, { Id: 2, CompanyId: 102, CompanyName: "Metro HVAC", ContactName: "Maya Chen", Email: "maya@metro.test", Phone: "314-555-0112", CompanyPhone: "314-555-0110", Address: "2 Pine St", City: "Clayton", State: "MO", Zip: "63105", TradeSpecialties: "HVAC", GoogleMapsUrl: "", Notes: "", TotalJobs: 18, IsActive: true, AreaId: null as number | null, AreaName: "", }, ]; const SERVICE_AREAS = [ { id: 2, name: "Central" }, { id: 1, name: "East" }, ]; const inactiveVendors = [ { ...activeVendors[0], Id: 3, CompanyName: "Drain Cleaning Bros", ContactName: "Drew Banks", TotalJobs: 151, IsActive: false, }, ]; const LONG_TEXT_VENDOR = { CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis", ContactName: "Adam Christopher Whyte Senior Field Technician", Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example", Phone: "+1 (314) 555-0198 extension 123456", Address: "New York, NY, Estados Unidos, 123 Long Address Avenue", City: "", State: "", }; interface MockState { listUrls: string[]; createdBody?: Record; updatedBody?: Record; patchedBody?: Record; patchedCompanyId?: string; deletedId?: string; deleteConfirmedOpenWorkOrders?: boolean; } async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } async function captureVisual(page: Page, path: string) { if (!process.env.VENDOR_VISUAL_CAPTURE) return; await page.waitForTimeout(400); await page.screenshot({ path, fullPage: true }); } async function mockVendorApi( page: Page, options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean; groupedDirectory?: boolean; legacyGateway?: boolean; longText?: boolean; } = {}, ) { const state: MockState = { listUrls: [] }; const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor })); if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR); if (options.legacyGateway) { vendorRecords[0].Phone = "555-1234 ext 9"; vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy"; } await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) => fulfillJson(route, { data: [ { id: 1, value: "Plumbing" }, { id: 2, value: "HVAC" }, ], }), ); await page.route("**/api/Vendor/facets*", (route) => fulfillJson(route, { data: { companies: activeVendors.map((vendor) => ({ companyId: vendor.CompanyId, name: vendor.CompanyName, companyPhone: vendor.CompanyPhone, email: vendor.Email, address: vendor.Address, city: vendor.City, state: vendor.State, zip: vendor.Zip, googleMapsUrl: vendor.GoogleMapsUrl, })), trades: ["Plumbing", "Backflow Preventers", "HVAC"], areas: SERVICE_AREAS, locations: [ { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, { city: "Clayton", state: "MO", label: "Clayton, MO" }, ], jobBuckets: [ { id: "under-50", label: "Under 50" }, { id: "50-99", label: "50–99" }, { id: "100-149", label: "100–149" }, { id: "150-plus", label: "150+" }, ], }, }), ); await page.route("**/api/Vendor/GetVendorDirectoryList*", async (route) => { const url = new URL(route.request().url()); state.listUrls.push(url.toString()); if (options.listError && url.searchParams.get("pageSize") !== "1") { await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); return; } const isActive = url.searchParams.get("isActive") !== "false"; const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive); const search = (url.searchParams.get("search") ?? "").toLowerCase(); const company = url.searchParams.get("companies[0]"); const areas = [...url.searchParams.entries()] .filter(([key]) => key.startsWith("areas[")) .map(([, value]) => value); const filtered = source.filter( (vendor) => (!search || `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}` .toLowerCase() .includes(search)) && (!company || vendor.CompanyName === company) && (areas.length === 0 || areas.some((area) => area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area, )), ); const isCount = url.searchParams.get("pageSize") === "1"; const responseRows = filtered.map((vendor) => { if (!options.groupedDirectory) return vendor; const technicians = [ { Id: vendor.Id, ContactName: vendor.ContactName, Email: vendor.Email, Phone: vendor.Phone, TradeSpecialties: vendor.TradeSpecialties, IsActive: vendor.IsActive, TotalJobs: vendor.TotalJobs, }, ]; if (vendor.Id === 1) { technicians.push({ Id: 91, ContactName: "Robin Rivers", Email: "robin@gateway.test", Phone: "314-555-0191", TradeSpecialties: "Drain Cleaning", IsActive: true, TotalJobs: 18, }); } return { ...vendor, TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0), Technicians: technicians, }; }); await fulfillJson(route, { Data: isCount ? responseRows.slice(0, 1) : responseRows, TotalCount: filtered.length, PageNumber: 1, PageSize: Number(url.searchParams.get("pageSize") ?? 12), }); }); await page.route(/\/api\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => { const request = route.request(); const url = new URL(request.url()); const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1]; if (request.method() === "POST") { state.createdBody = request.postDataJSON(); await fulfillJson(route, { ...state.createdBody, companyId: 999, rowVersion: "rv-created", }); return; } if (request.method() === "PATCH" && pathCompanyId) { state.patchedBody = request.postDataJSON(); state.patchedCompanyId = pathCompanyId; const anchor = vendorRecords.find((vendor) => String(vendor.CompanyId) === pathCompanyId); if (!anchor) { await fulfillJson(route, { message: "Vendor roster not found" }, 404); return; } const added = Array.isArray(state.patchedBody.addTechnicians) ? (state.patchedBody.addTechnicians as Array>) : []; await fulfillJson(route, { companyId: anchor.CompanyId, rowVersion: "rv-patched", name: anchor.CompanyName, companyPhone: anchor.CompanyPhone, email: anchor.Email, address: anchor.Address, city: anchor.City, state: anchor.State, zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, areaId: anchor.AreaId, areaName: anchor.AreaName, technicians: [ ...vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) .map((vendor) => ({ id: vendor.Id, contactName: vendor.ContactName, phone: vendor.Phone, email: vendor.Email, preferredContact: vendor.PreferredContact ?? "Phone", tradeSpecialties: vendor.TradeSpecialties, isActive: vendor.IsActive, totalJobs: vendor.TotalJobs, })), ...added.map((technician, index) => ({ id: 900 + index, totalJobs: 0, ...technician, })), ], }); return; } if (request.method() === "PUT" && pathCompanyId) { state.updatedBody = request.postDataJSON(); const technicians = Array.isArray(state.updatedBody.technicians) ? (state.updatedBody.technicians as Array>) : []; for (const technician of technicians) { const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id)); if (vendor && typeof technician.isActive === "boolean") { vendor.IsActive = technician.isActive; } } await fulfillJson(route, { ...state.updatedBody, companyId: Number(pathCompanyId), rowVersion: "rv-updated", }); return; } const vendorId = url.searchParams.get("vendorId"); const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId; const anchor = vendorId ? vendorRecords.find((vendor) => String(vendor.Id) === vendorId) : vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId); if (!anchor) { await fulfillJson(route, { message: "Vendor roster not found" }, 404); return; } await fulfillJson(route, { companyId: anchor.CompanyId, rowVersion: "rv-1", name: anchor.CompanyName, companyPhone: anchor.CompanyPhone, email: anchor.Email, address: anchor.Address, city: anchor.City, state: anchor.State, zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, areaId: anchor.AreaId, areaName: anchor.AreaName, technicians: vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) .map((vendor) => ({ id: vendor.Id, contactName: vendor.ContactName, phone: vendor.Phone, email: vendor.Email, preferredContact: vendor.PreferredContact ?? "Phone", tradeSpecialties: vendor.TradeSpecialties, isActive: vendor.IsActive, totalJobs: vendor.TotalJobs, })), }); }); await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => { const vendorId = Number(route.request().url().split("/").at(-2)); await fulfillJson(route, { data: { vendorId, canDeactivate: !options.deactivationBlocked, openWorkOrders: options.deactivationBlocked ? [ { workOrderId: 501, workOrderNumber: "WO-501", workOrderTitle: "Emergency boiler repair", status: "Dispatched", scheduledDate: "2026-07-24T14:00:00Z", dispatchId: 801, }, ] : [], }, }); }); await page.route(/\/api\/vendors\/\d+\/portal-token$/, (route) => fulfillJson(route, { data: { portalUrl: "https://vendor.example.test/token", issuedAt: "2026-07-31T12:00:00Z", expiresAt: "2026-08-07T12:00:00Z", }, }), ); await page.route(/\/api\/vendors\/\d+(\?.*)?$/, async (route) => { const requestUrl = new URL(route.request().url()); const id = requestUrl.pathname.split("/").pop() ?? ""; if (route.request().method() === "PUT") { state.updatedBody = route.request().postDataJSON(); const vendor = vendorRecords.find((item) => String(item.Id) === id); if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") { vendor.IsActive = state.updatedBody.isActive; } await fulfillJson(route, { data: { ...vendor, ...state.updatedBody, Id: Number(id) }, }); return; } if (route.request().method() === "DELETE") { state.deleteConfirmedOpenWorkOrders = requestUrl.searchParams.get("confirmOpenWorkOrders") === "true"; if (options.deleteConflict) { await fulfillJson( route, { message: "Vendor has open work orders and cannot be deactivated." }, 409, ); return; } state.deletedId = id; const vendor = vendorRecords.find((item) => String(item.Id) === id); if (options.legacyGateway && vendor) vendor.IsActive = false; await fulfillJson(route, { status: "200" }); return; } const vendor = vendorRecords.find((item) => String(item.Id) === id); await fulfillJson(route, { data: vendor }); }); await page.route(/\/api\/vendors\/?$/, async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } state.createdBody = route.request().postDataJSON(); await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } }); }); return state; } test.describe("Vendor directory prototype parity", () => { test("renders company-owned columns and the grouped technician roster", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await mockVendorApi(page, { groupedDirectory: true }); await page.goto("/vendors"); const expectedHeaders = [ "Company", "Company Phone", "Company Email", "Street Address", "City", "State", "Service Area", "Trade", "Technician", "Technician Phone", "Total Jobs", "Status", ]; await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1); for (const header of expectedHeaders) { await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible(); } const gatewayRow = page.getByRole("button", { name: "Open vendor details for Gateway Plumbing", }); await expect(gatewayRow).toContainText("Adam Whyte"); await expect(gatewayRow).toContainText("90"); const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge"); await expect(rosterBadge).toHaveText("+1"); await rosterBadge.hover(); await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers"); await rosterBadge.focus(); await expect(rosterBadge).toBeFocused(); await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers"); }); test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); await expect(heading).toBeVisible(); await expect(heading).toHaveCSS("font-family", /Montserrat/); await expect(heading).toHaveCSS("font-size", "30px"); await expect(heading).toHaveCSS("font-weight", "800"); await expect(heading).toHaveCSS("line-height", "33px"); await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); await expect(page.getByText("2 active companies").first()).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", ); for (const heading of [ "Company", "Company Phone", "Company Email", "Street Address", "City", "State", "Service Area", "Trade", "Technician", "Technician Phone", "Total Jobs", ]) { await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible(); } await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); const tableContainerBox = await page.locator("table").locator("..").boundingBox(); expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); expect(tableContainerBox?.height).toBeCloseTo(738, 0); expect((await page.locator("table").boundingBox())?.width).toBe(2200); expect( (await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox()) ?.height, ).toBe(47); expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); const tradeBadge = page.getByTestId("vendor-trade-badge").first(); await expect(tradeBadge).toHaveText("+1"); await tradeBadge.hover(); await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers"); await tradeBadge.focus(); await expect(tradeBadge).toBeFocused(); await tradeBadge.click(); await expect(page.getByRole("dialog")).toHaveCount(0); await tradeBadge.press("Enter"); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("2 active companies").last()).toBeVisible(); await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); await page.getByPlaceholder("Search name, company, trade…").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); await expect(page.getByText("1 company · filtered by")).toBeVisible(); await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); await page.getByRole("tab", { name: "Inactive (1)" }).click(); await expect(page.getByText("No inactive companies found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); test("truncates every non-abbreviated textual column", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await mockVendorApi(page, { longText: true }); await page.goto("/vendors"); const row = page.locator("tbody tr").first(); const fields = [ { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, { cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email }, { cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address }, { cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, { cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, ]; for (const expected of fields) { const cell = row.locator("td").nth(expected.cellIndex); const field = cell.getByTestId(expected.testId); await expect(field).toBeVisible(); const metrics = await field.evaluate((element) => { const fieldRect = element.getBoundingClientRect(); const cellRect = element.closest("td")?.getBoundingClientRect(); return { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, withinCell: cellRect !== undefined && fieldRect.left >= cellRect.left - 1 && fieldRect.right <= cellRect.right + 1, }; }); expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth); expect(metrics.withinCell).toBe(true); await field.hover(); await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible(); await page.mouse.move(0, 0); await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0); } }); test("adds a technician to an existing company, then views and edits the aggregate", async ({ page, }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); await expect( page.getByRole("button", { name: "Add Vendor", exact: true }).last(), ).toBeEnabled(); const companyLabel = page .getByRole("dialog", { name: /Add Vendor/ }) .locator("label") .filter({ hasText: "Company (required)" }); await expect(companyLabel).toBeVisible(); const companyLabelMetrics = await companyLabel.evaluate((element) => { const field = element.closest(".MuiFormControl-root"); const labelRect = element.getBoundingClientRect(); const fieldRect = field?.getBoundingClientRect(); return { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, withinFieldGeometry: fieldRect ? labelRect.left >= fieldRect.left && labelRect.right <= fieldRect.right && labelRect.top >= fieldRect.top && labelRect.bottom <= fieldRect.bottom : false, }; }); expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth); expect(companyLabelMetrics.withinFieldGeometry).toBe(true); await page.getByRole("combobox", { name: "Company (required)" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); const floatedCompanyLabel = page .getByRole("dialog", { name: /Add Vendor/ }) .locator("label") .filter({ hasText: "Company (required)" }); const floatedCompanyField = floatedCompanyLabel.locator(".."); await expect(floatedCompanyLabel).toHaveText("Company (required)"); await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)"); const floatedMetrics = await floatedCompanyLabel.evaluate((element) => { const labelRect = element.getBoundingClientRect(); const legendRect = element .closest(".MuiFormControl-root") ?.querySelector("legend") ?.getBoundingClientRect(); return { labelClientWidth: element.clientWidth, labelScrollWidth: element.scrollWidth, legendWidth: legendRect?.width ?? 0, labelWidth: labelRect.width, }; }); expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth); expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth); // Scrolling focus down the form and back to Company must keep the floated label // inside the dialog's visible scrollport. const dialogContent = page .getByRole("dialog", { name: /Add Vendor/ }) .locator(".MuiDialogContent-root"); await page.getByLabel("Technician name (optional)").click(); await expect .poll(() => dialogContent.evaluate((element) => element.scrollTop)) .toBeGreaterThan(0); await page.getByRole("combobox", { name: "Company (required)" }).click(); await expect .poll(() => dialogContent.evaluate((element) => element.scrollTop)) .toBeLessThanOrEqual(1); await expect(floatedCompanyLabel).toBeVisible(); const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => { const content = element.closest(".MuiDialogContent-root"); return { labelTop: element.getBoundingClientRect().top, labelBottom: element.getBoundingClientRect().bottom, dialogTop: content?.getBoundingClientRect().top ?? Number.NaN, dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN, overflowY: content ? getComputedStyle(content).overflowY : "", }; }); expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY); expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual( labelScrollportMetrics.dialogTop - 1, ); expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual( labelScrollportMetrics.dialogBottom + 1, ); await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", ); // SH-271: one combined "Address (optional)" line became three required // fields, and the hand-pasted Google Maps URL input is gone — the location // is derived from the address now. await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue( "1 Market St", ); await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis"); await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)"); await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East"); await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0); await expect(page.getByLabel("Address (optional)")).toHaveCount(0); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1); await expect(page.getByLabel("Technician name (optional)")).toHaveValue(""); await expect( page.getByRole("dialog", { name: /Add Vendor/ }).getByText("Adam Whyte"), ).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await expect(page.getByRole("button", { name: /Move (Plumbing|HVAC) (up|down)/ })).toHaveCount( 0, ); await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); expect(state.patchedCompanyId).toBe("101"); expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" }); // Only the field the user actually typed into is transmitted. Address/phone/email were // populated by selecting the company and never edited, so they must not be sent — otherwise a // concurrent edit by another user to any of them would be silently reverted on resubmit. expect(state.patchedBody?.companyFields).toEqual({ notes: "Created in browser E2E" }); expect(state.patchedBody?.companyFields).not.toHaveProperty("companyPhone"); expect(state.patchedBody?.companyFields).not.toHaveProperty("address"); expect(state.patchedBody?.companyFields).not.toHaveProperty("email"); expect(state.patchedBody?.companyFields).not.toHaveProperty("name"); expect(state.patchedBody?.addTechnicians).toEqual([ expect.objectContaining({ contactName: "New Technician", tradeSpecialties: "Plumbing, HVAC", }), ]); const patchedTechnicians = Array.isArray(state.patchedBody?.addTechnicians) ? (state.patchedBody.addTechnicians as Array>) : []; const newTechnician = patchedTechnicians.find( (technician) => technician.contactName === "New Technician", ); expect(newTechnician?.preferredContact).toBeUndefined(); expect(patchedTechnicians.some((technician) => technician.contactName === "Adam Whyte")).toBe( false, ); expect(state.updatedBody).toBeUndefined(); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); // SH-271: the separate "Open in Google Maps" row is gone — the Street // Address itself is the link, built from the structured parts. const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first(); await expect(streetLink).toHaveAttribute("target", "_blank"); await expect(streetLink).toHaveAttribute( "href", "https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO", ); await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0); await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); await expect(page).toHaveURL(/\/vendors$/); await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); test("filters by Service Area and lets an admin assign one", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); const gatewayRow = page.getByRole("button", { name: "Open vendor details for Gateway Plumbing", }); const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" }); await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East"); await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned"); await page.getByRole("button", { name: "Open vendor filters" }).click(); await page.getByRole("button", { name: "Service Area" }).click(); await page.getByPlaceholder("All areas").click(); await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]); await page.getByRole("option", { name: "East" }).click(); await page.getByRole("button", { name: "Apply filters" }).click(); await expect( page.getByRole("button", { name: "Remove filter Service Area: East" }), ).toBeVisible(); await expect(gatewayRow).toBeVisible(); await expect(metroRow).toHaveCount(0); expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe( true, ); await page.getByRole("button", { name: "Open vendor filters" }).click(); await page.getByRole("button", { name: "Service Area" }).click(); await page.getByRole("button", { name: "Clear all" }).last().click(); await page.getByPlaceholder("All areas").click(); await page.getByRole("option", { name: "Unassigned" }).click(); await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Service Area: Unassigned")).toBeVisible(); await expect(metroRow).toBeVisible(); await expect(gatewayRow).toHaveCount(0); await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" }); await expect(areaField).toBeEnabled(); await areaField.click(); await page.getByRole("option", { name: "Central" }).click(); await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody?.areaId).toBe(2); expect(state.updatedBody?.state).toBe("MO"); }); test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await detailDrawer.getByRole("switch", { name: "Active status" }).click(); await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); test("supports keyboard dismissal and free-text company creation", async ({ page }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); await expect(addButton).toBeFocused(); await addButton.click(); await expect( page.getByRole("button", { name: "Add Vendor", exact: true }).last(), ).toBeEnabled(); await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC"); await page.getByLabel("Company Phone (optional)").fill("3145550199"); // SH-271 made the structured address required. This test is about keyboard // dismissal and free-text company creation, so the address is filled to // leave those as the only things it is exercising. await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St"); await page.getByLabel("City (required)").fill("St. Louis"); await page.getByLabel("State (required)").fill("Missouri"); await page.getByRole("option", { name: "Missouri (MO)" }).click(); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); expect(state.createdBody?.state).toBe("MO"); expect(state.createdBody?.areaId).toBeNull(); }); test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => { await mockVendorApi(page); await page.goto("/vendors"); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await expect(detailDrawer).toBeVisible(); await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible(); await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0); }); test("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({ page, }) => { const confirmState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("switch", { name: "Active status" }).click(); const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" }); await expect(dialog).toContainText("It still has 1 open work order"); await expect( dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }), ).toHaveAttribute("href", "/workorders/501"); // SH-254: the open work orders inform the decision, they no longer block it. const confirm = dialog.getByRole("button", { name: "Deactivate anyway" }); await expect(confirm).toBeEnabled(); await confirm.click(); await expect.poll(() => confirmState.deletedId).toBe("1"); expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true); await page.unrouteAll({ behavior: "wait" }); const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); await page.getByRole("switch", { name: "Active status" }).click(); await page .getByRole("dialog", { name: "Deactivate this vendor?" }) .getByRole("button", { name: /^Deactivate$/, }) .click(); await expect(page.getByRole("dialog", { name: "Deactivate this vendor?" })).toContainText( /open work orders|conflict/i, ); expect(racedState.deletedId).toBeUndefined(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockVendorApi(page, { listError: true }); await page.goto("/vendors"); const mainBox = await page.getByRole("main").boundingBox(); expect(mainBox).toMatchObject({ x: 0, width: 390 }); const navigation = page.getByRole("navigation", { name: "Primary navigation" }); await expect(navigation).not.toBeVisible(); const openNavigation = page.getByRole("button", { name: "Toggle navigation" }); await openNavigation.focus(); await page.keyboard.press("Enter"); await expect(navigation).toBeVisible(); await expect(page.getByRole("link", { name: "Sites" })).toBeVisible(); await page.keyboard.press("Escape"); await expect(navigation).not.toBeVisible(); await expect(openNavigation).toBeFocused(); await expect(page.getByRole("main").getByRole("alert")).toContainText( /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, ); await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png"); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); const box = await filterDrawer.boundingBox(); expect(box?.width).toBeLessThanOrEqual(391); await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); await page.getByRole("button", { name: "Close filters" }).click(); }); test("keeps protected shell destinations available and marks their selected state", async ({ page, }) => { await mockVendorApi(page); for (const destination of [ { path: "/workorders", label: "Work Orders" }, { path: "/accounts", label: "Accounts" }, { path: "/locations", label: "Sites" }, { path: "/notifications", label: "Notifications" }, ]) { await page.goto(destination.path); await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute( "aria-current", "page", ); } }); });