From 251edd9c61d2189ecf4ec3c3dd815173c0872452 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 11 Aug 2026 13:54:31 -0300 Subject: [PATCH] fix(work-orders): drop vendor collateral from slide-over PR Restore vendors and page-header to origin/dev so this PR stays the WO slide-over/media slice only. Remove stale godfile baseline entries now under the 500-line cap. --- e2e/vendors/vendors.spec.ts | 147 ++++-- e2e/vendors/vendors.visual.spec.ts | 261 +++++++++++ scripts/governance-baseline.json | 13 +- .../_components/use-vendor-deactivation.ts | 6 +- .../_components/use-vendor-list-state.ts | 2 +- .../_components/use-vendor-roster-form.ts | 16 +- .../_components/vendor-create-modal.tsx | 33 +- .../_components/vendor-detail-drawer.tsx | 418 ++++++++++++------ .../_components/vendor-filter-options.tsx | 252 +++++++++++ .../_components/vendor-filters-drawer.tsx | 239 +--------- .../_components/vendor-roster-form-fields.tsx | 118 ++--- .../_components/vendor-roster-page.tsx | 47 -- .../_components/vendors-list-toolbar.tsx | 147 +++--- .../vendors/_components/vendors-table.tsx | 242 ++++++---- src/app/(protected)/vendors/index.tsx | 125 +++++- src/components/ui/page-header.tsx | 25 +- .../use-save-vendor-company-roster.ts | 5 - .../vendors/use-vendor-roster-form.test.tsx | 136 ++---- .../vendors/vendor-detail-drawer.test.tsx | 58 ++- .../(protected)/vendors/vendors-list.test.tsx | 166 +++++-- .../use-save-vendor-company-roster.test.ts | 75 +--- 21 files changed, 1573 insertions(+), 958 deletions(-) create mode 100644 e2e/vendors/vendors.visual.spec.ts create mode 100644 src/app/(protected)/vendors/_components/vendor-filter-options.tsx diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 8bbfdfb8..ad1a89e8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => { 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"); - await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); - await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); + 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("3 technicians · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", - "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).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(1550); + expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( + 47, + ); + expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active technicians")).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 page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active technician · 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 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.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" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText("1 technician · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); - await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( - "history will be preserved", - ); - await page.getByRole("button", { name: "Deactivate", exact: true }).click(); - await expect.poll(() => state.deletedId).toBe("1"); - await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); + await expect(page.getByText("No inactive technicians 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); }); @@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "+ Add Vendor" }).click(); - await expect( - page.getByText("Register a company and add zero, one, or multiple technicians."), - ).toBeVisible(); + 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 Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( + 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", ); - await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); + await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( + "https://maps.google.com/gateway", + ); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician Name").last().fill("New Technician"); + await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); - await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).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.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByLabel("Notes").fill("Updated in browser E2E"); - await page.getByRole("button", { name: "Save" }).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"); }); @@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + 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: "View vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByRole("switch", { name: "Inactive" }).first().click(); - await page.getByRole("button", { name: "Save" }).click(); + 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(); }); @@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "+ Add Vendor" }); + 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(); @@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await page.getByLabel("Company Phone (optional)").fill("3145550199"); + 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([]); @@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -537,10 +556,25 @@ test.describe("Vendor directory prototype parity", () => { 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(); @@ -549,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => { 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", + ); + } + }); }); diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts new file mode 100644 index 00000000..705c4f4e --- /dev/null +++ b/e2e/vendors/vendors.visual.spec.ts @@ -0,0 +1,261 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "vendor-visual-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Vendor Visual 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", + }, + { + 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, + }, +]; + +const inactiveVendors = [ + { + ...activeVendors[0], + Id: 3, + CompanyId: 103, + CompanyName: "Drain Cleaning Bros", + ContactName: "Drew Banks", + TotalJobs: 151, + IsActive: false, + }, +]; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") { + await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z")); + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + + if (!url.pathname.startsWith("/api/")) { + await route.continue(); + return; + } + + if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) { + await fulfillJson(route, { + data: [ + { id: 1, value: "Plumbing" }, + { id: 2, value: "HVAC" }, + ], + }); + return; + } + + if (url.pathname.endsWith("/api/Vendor/facets")) { + await fulfillJson(route, { + data: { + companies: activeVendors.map((vendor) => ({ + companyId: vendor.CompanyId, + name: vendor.CompanyName, + })), + trades: ["Plumbing", "Backflow Preventers", "HVAC"], + 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+" }, + ], + }, + }); + return; + } + + if (url.pathname.endsWith("/api/Vendor/GetVendorList")) { + const isCount = url.searchParams.get("pageSize") === "1"; + if (mode === "error" && !isCount) { + await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); + return; + } + const source = + mode === "empty" + ? [] + : url.searchParams.get("isActive") === "false" + ? inactiveVendors + : activeVendors; + await fulfillJson(route, { + Data: isCount ? source.slice(0, 1) : source, + TotalCount: source.length, + PageNumber: 1, + PageSize: Number(url.searchParams.get("pageSize") ?? 12), + }); + return; + } + + if (url.pathname.startsWith("/api/vendor-company-roster")) { + await fulfillJson(route, { + companyId: 101, + rowVersion: "rv-visual", + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "https://maps.google.com/gateway", + notes: "Preferred after-hours vendor", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "314-555-0198", + email: "dispatch@gateway.test", + preferredContact: "Email", + tradeSpecialties: "Plumbing, Backflow Preventers", + isActive: true, + totalJobs: 72, + }, + ], + }); + return; + } + + await fulfillJson(route, { data: {} }); + }); +} + +async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") { + await mockVendorApi(page, mode); + await page.goto("/vendors"); + await page.evaluate(() => document.fonts.ready); + await page.addStyleTag({ + content: ` + *, *::before, *::after { transition: none !important; animation: none !important; } + .MuiDrawer-paper { transform: none !important; } + .Toastify__toast-container { display: none !important; } + .tsqd-parent-container { display: none !important; } + `, + }); +} + +async function expectStableScreenshot(page: Page, name: string) { + await page.waitForTimeout(250); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await page.screenshot({ animations: "disabled", caret: "hide" }); + await expect(page).toHaveScreenshot(name); +} + +test.describe("Vendor deterministic pixel regression", () => { + test("list", async ({ page }) => { + await openVendorPage(page); + await expect(page.getByText("Gateway Plumbing")).toBeVisible(); + await expectStableScreenshot(page, "vendor-list.png"); + }); + + test("filter", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-filter.png"); + }); + + test("add", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); + await expectStableScreenshot(page, "vendor-add.png"); + }); + + test("detail", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-detail.png"); + }); + + test("edit", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-edit.png"); + }); + + test("inactive", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("tab", { name: "Inactive (1)" }).click(); + await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expectStableScreenshot(page, "vendor-inactive.png"); + }); + + test("empty", async ({ page }) => { + await openVendorPage(page, "empty"); + await expect(page.getByText("No active technicians found")).toBeVisible(); + await expectStableScreenshot(page, "vendor-empty.png"); + }); + + test("error", async ({ page }) => { + await openVendorPage(page, "error"); + await expect(page.getByRole("main").getByRole("alert")).toContainText( + /server error|vendor directory unavailable/i, + ); + await expectStableScreenshot(page, "vendor-error.png"); + }); + + test("mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openVendorPage(page); + await expect(page.getByText("Gateway Plumbing")).toBeVisible(); + await expectStableScreenshot(page, "vendor-mobile.png"); + + await page.getByRole("button", { name: "Toggle navigation" }).click(); + await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-mobile-navigation.png"); + }); +}); diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json index 401e1e70..3862e898 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -2,16 +2,5 @@ "version": 1, "purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.", "maxFileLines": 500, - "godfileDebt": [ - { - "path": "src/app/(protected)/workorders/[id].tsx", - "maxLines": 797, - "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components." - }, - { - "path": "src/app/(protected)/vendors/index.tsx", - "maxLines": 612, - "reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction." - } - ] + "godfileDebt": [] } diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index 2409814f..b63291be 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,9 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(options?: { - onSuccess?: () => void; -}): VendorDeactivationState { +export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -43,7 +41,7 @@ export function useVendorDeactivation(options?: { deleteVendor.mutate(target.id, { onSuccess: () => { setTarget(null); - options?.onSuccess?.(); + onSuccess?.(); }, onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index 9385a834..fdc90685 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); + const [pageSize, setPageSize] = useState(48); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index b4c3a51f..6bf89b1f 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, + emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -43,6 +44,7 @@ export interface VendorRosterFormProps { vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; + startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -73,6 +75,7 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, + startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -87,9 +90,16 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); + const createDefaults = useMemo( + () => ({ + ...emptyVendorCompanyRosterForm, + technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], + }), + [startWithTechnician], + ); const form = useForm({ resolver, - defaultValues: emptyVendorCompanyRosterForm, + defaultValues: createDefaults, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -114,10 +124,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(emptyVendorCompanyRosterForm); + reset(createDefaults); resetSelection(); clearConflict(); - }, [clearConflict, reset, resetSelection]); + }, [clearConflict, createDefaults, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index d0025662..31804a3c 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,7 +21,11 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const form = useVendorRosterForm({ + mode: "create", + onSuccess: onClose, + startWithTechnician: true, + }); const { resetForm } = form; useEffect(() => { @@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="md" + maxWidth="sm" aria-labelledby="vendor-create-dialog-title" - slotProps={{ paper: { role: "dialog" } }} + slotProps={{ + paper: { + role: "dialog", + sx: { maxHeight: "88vh", borderRadius: 2 }, + }, + }} > - Add Vendor Company - - Register a company and add zero, one, or multiple technicians. - + + Add Vendor + + + Register a vendor company and its technicians. + - + {form.loadErrorMessage !== null && ( @@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving || !form.isValid} + disabled={form.isSaving} > - {form.isSaving ? : "Add Vendor Company"} + {form.isSaving ? : "Add Vendor"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index ad7ffa29..74d6ef02 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useEffect, useState, type ReactNode } from "react"; +import { Controller, useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,15 +13,22 @@ import { Drawer, IconButton, Link, - List, - ListItem, Stack, - Typography, + Switch, } from "@mui/material"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; +import { Text } from "@/components/ui/text"; +import type { + VendorCompanyRoster, + VendorListItem, + VendorRosterTechnician, +} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 440; +const DRAWER_WIDTH = 420; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; + startInEdit?: boolean; + onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; +function Field({ label, value }: { label: string; value?: string }) { + const display = !value || value.trim() === "" ? "—" : value; return ( - + {label} - - {display} + + {display} ); } -function TechnicianEntry({ - contactName, - phone, - email, - preferredContact, - tradeSpecialties, - isActive, - totalJobs, -}: { - contactName: string; - phone: string; - email: string; - preferredContact?: string; - tradeSpecialties: string; - isActive: boolean; - totalJobs: number; -}) { - const trades = splitTrades(tradeSpecialties); - return ( - - - - - {contactName || "—"} - - - - {totalJobs} jobs - - - - {Boolean(phone) && ( - - {phone} - - )} - {Boolean(email) && ( - - {email} - - )} - - - {Boolean(preferredContact) && ( - - Prefers {preferredContact} - - )} - {trades.map((trade, index) => ( - - ))} - - - - ); -} - function DrawerHeader({ roster, vendor, + technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; + technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -141,23 +87,18 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 3, + px: 2.5, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {roster?.name || vendor?.companyName || "Vendor company"} - - - {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} - + + {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} + + + {roster?.name || vendor?.companyName || "Vendor details"} + @@ -167,18 +108,19 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const mapsUrl = roster.googleMapsUrl; + const address = [roster.address, roster.city, roster.state, roster.zip] + .filter(Boolean) + .join(", "); + return ( + - - - {Boolean(mapsUrl) && ( + + + {Boolean(roster.googleMapsUrl) && ( - Technicians - {roster.technicians.length === 0 ? ( - - No technicians assigned to this company. - - ) : ( - - {roster.technicians.map((technician) => ( - + + + + Trade + + + {trades.length > 0 ? ( + trades.map((trade, index) => ( + + )) + ) : ( + — + )} + + + + + {technician.totalJobs ?? 0} + + Total Jobs + + + + + Status + + - ))} - - )} + + + ); } -function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { +function DrawerBody({ + roster, + technician, +}: { + roster: VendorCompanyRoster; + technician?: VendorRosterTechnician; +}) { return ( - - Company - + + + Company + + - + {technician ? ( + + ) : ( + + No technicians assigned to this company. + + )} {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { - const navigate = useNavigate(); +function DrawerEditor({ + vendor, + onClose, + onSaved, + onRequestDeactivation = () => undefined, +}: { + vendor: VendorListItem; + onClose: () => void; + onSaved: () => void; + onRequestDeactivation: (vendor: VendorListItem) => void; +}) { + const form = useVendorRosterForm({ + mode: "update", + vendorId: vendor.id ?? undefined, + companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, + onSuccess: onSaved, + }); + const roster = form.roster; + const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; + const selectedIndex = technicians.findIndex( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + ); + const selectedTotalJobs = + roster?.technicians.find( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + )?.totalJobs ?? vendor.totalJobs; + + if (form.isLoading) { + return ; + } + if (form.isError || !roster) { + return ( + + Failed to load vendor company. Try again. + + ); + } + + return ( + + + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( + + )} + + {selectedIndex >= 0 && ( + <> + + + + {selectedTotalJobs} + + Total Jobs + + + ( + + {field.value ? "Active" : "Inactive"} + { + if (field.value && !checked) onRequestDeactivation(vendor); + else field.onChange(checked); + }} + /> + + )} + /> + + + )} + + + + + + + ); +} + +function resolveTechnician( + roster: VendorCompanyRoster | undefined, + vendor: VendorListItem | null, +): VendorRosterTechnician | undefined { + if (!roster || !vendor) return undefined; + + const match = roster.technicians.find( + (technician) => + vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), + ); + if (match) return match; + if (!vendor.contactName) return undefined; + + return { + id: vendor.id ?? undefined, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + tradeSpecialties: vendor.tradeSpecialties, + isActive: vendor.isActive, + totalJobs: vendor.totalJobs, + }; +} + +export function VendorDetailDrawer({ + open, + vendor, + startInEdit = false, + onRequestDeactivation = () => undefined, + onClose, +}: VendorDetailDrawerProps) { + const [isEditing, setIsEditing] = useState(startInEdit); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer !hasVendorId ? (companyId ?? undefined) : undefined, ); - const editHref = hasVendorId - ? `/vendors/edit/${vendorId}` - : `/vendors/company/${companyId ?? ""}`; + const selectedTechnician = resolveTechnician(roster, vendor); + + useEffect(() => { + if (open) setIsEditing(startInEdit); + }, [open, startInEdit, vendor]); useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); + if (event.key === "Escape" && !isEditing) onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [onClose, open]); + }, [isEditing, onClose, open]); return ( { + if (reason === "escapeKeyDown" && isEditing) return; + onClose(); + }} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer }} > - + - {isLoading ? ( + {isEditing && vendor ? ( + setIsEditing(false)} + onRequestDeactivation={onRequestDeactivation} + /> + ) : isLoading ? ( @@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer ) : roster ? ( <> - - navigate(editHref)} /> + + setIsEditing(true)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx new file mode 100644 index 00000000..c9be7d45 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -0,0 +1,252 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; + +type DraftSetter = Dispatch>; + +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + +function CompanyFilter({ + options, + draft, + setDraft, +}: { + options: VendorFacetCompany[]; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + const allSelected = draft.companies.length === options.length && options.length > 0; + return ( + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={options.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +interface FilterProps { + options: T[]; + draft: VendorFilters; + setDraft: DraftSetter; +} + +function LocationFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={options.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobCountFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={options.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFilterOptions({ + facets, + draft, + setDraft, +}: { + facets: VendorFacets | undefined; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index ae4b51ba..e3b91cf5 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,32 +1,9 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; -import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import { useEffect, useState } from "react"; import CloseIcon from "@mui/icons-material/Close"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; -import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - Checkbox, - Chip, - Drawer, - IconButton, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, -} from "@/domain/vendors/types/vendor"; +import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import { VendorFilterOptions } from "./vendor-filter-options"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps { const DRAWER_WIDTH = 360; -function FilterSection({ - title, - icon, - children, -}: { - title: string; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - export function VendorFiltersDrawer({ open, onClose, @@ -74,23 +25,12 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) { - setDraft(appliedFilters); - } + if (open) setDraft(appliedFilters); }, [open, appliedFilters]); - const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); - const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); - const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); - const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); - const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; - const handleClear = () => { - setDraft(emptyVendorFilters); - }; - const handleApply = () => { onApply(draft); onClose(); @@ -133,165 +73,7 @@ export function VendorFiltersDrawer({
    - }> - - - Select vendor companies - - - - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={companyOptions.filter((option) => draft.companies.includes(option.name))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetCompany)[]).map((entry) => - typeof entry === "string" ? entry : entry.name, - ); - setDraft((current) => ({ ...current, companies: next })); - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.label === value.label - } - value={locationOptions.filter((option) => draft.locations.includes(option.label))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetLocation)[]).map((entry) => - typeof entry === "string" ? entry : entry.label, - ); - setDraft((current) => ({ ...current, locations: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.id === value.id - } - value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => - typeof entry === "string" ? entry : entry.id, - ); - setDraft((current) => ({ ...current, jobBuckets: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - +
    `1px solid ${theme.palette.divider}`, }} > -
    diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index d54c3016..d6d5cbf7 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -1,10 +1,4 @@ -import { - Controller, - useFieldArray, - useWatch, - type Control, - type FieldErrors, -} from "react-hook-form"; +import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form"; import AddIcon from "@mui/icons-material/Add"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import { @@ -34,7 +28,7 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; - onRequestDeactivate?: (technician: RosterTechnicianValues) => void; + showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -61,6 +55,7 @@ function CompanyNameField({ )} @@ -126,10 +122,7 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - - Company - - + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message ?? "Phone or email required"} + helperText={errors.companyPhone?.message} fullWidth /> )} @@ -162,7 +155,8 @@ function CompanyFields({ render={({ field }) => ( } + render={({ field }) => ( + + )} /> - + ( )} @@ -213,7 +214,14 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -228,7 +236,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; - onRequestDeactivate?: (technician: RosterTechnicianValues) => void; + showStatus: boolean; } function TechnicianRow({ @@ -238,15 +246,13 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, - onRequestDeactivate, + showStatus, }: TechnicianRowProps) { - const technician = useWatch({ control, name: `technicians.${index}` }); - return ( - Technician {index + 1} + {index + 1} - TECHNICIAN @@ -267,7 +273,8 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -303,32 +310,29 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth + sx={{ display: "none" }} /> )} /> - ( - { - if (!checked && technician?.id != null && onRequestDeactivate != null) { - onRequestDeactivate(technician); - return; - } - field.onChange(checked); - }} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> + {showStatus && ( + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + )} ); } @@ -337,12 +341,12 @@ function TechniciansFieldArray({ control, errors, tradeOptions, - onRequestDeactivate, + showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; - onRequestDeactivate?: (technician: RosterTechnicianValues) => void; + showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -385,7 +389,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} - onRequestDeactivate={onRequestDeactivate} + showStatus={showStatus} /> )) )} @@ -395,7 +399,7 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], onRequestDeactivate } = props; + const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; return ( @@ -404,7 +408,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { control={control} errors={errors} tradeOptions={tradeOptions} - onRequestDeactivate={onRequestDeactivate} + showStatus={showTechnicianStatus} /> ); diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx index cf4e6daf..15f8ff9a 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -1,44 +1,17 @@ import type { ReactNode } from "react"; import { useNavigate } from "react-router"; import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; -import { VendorDeactivationDialog } from "./vendor-deactivation-dialog"; import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; -import { useVendorDeactivation } from "./use-vendor-deactivation"; import { useVendorRosterForm } from "./use-vendor-roster-form"; -import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; interface VendorRosterPageProps { vendorId?: string; companyId?: string; } -function toDeactivationListItem( - roster: VendorCompanyRoster, - technician: RosterTechnicianValues, -): VendorListItem { - return { - id: technician.id ?? null, - companyId: roster.companyId, - companyName: roster.name, - contactName: technician.contactName, - email: technician.email, - phone: technician.phone, - companyPhone: roster.companyPhone, - googleMapsUrl: roster.googleMapsUrl, - notes: roster.notes, - totalJobs: 0, - city: roster.city, - state: roster.state, - tradeSpecialties: technician.tradeSpecialties, - isActive: technician.isActive, - preferredContact: technician.preferredContact ?? "Phone", - }; -} - function PageShell({ title, subtitle, @@ -70,9 +43,6 @@ function PageShell({ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { const navigate = useNavigate(); const isEdit = vendorId !== undefined || companyId !== undefined; - const deactivation = useVendorDeactivation({ - onSuccess: () => navigate("/vendors"), - }); const form = useVendorRosterForm({ mode: isEdit ? "update" : "create", vendorId, @@ -80,11 +50,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa onSuccess: () => navigate("/vendors"), }); - const handleRequestDeactivate = (technician: RosterTechnicianValues) => { - if (!form.roster || technician.id == null) return; - deactivation.open(toDeactivationListItem(form.roster, technician)); - }; - if (isEdit && form.isLoading) { return ( @@ -139,7 +104,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa control={form.control} errors={form.errors} tradeOptions={form.trades} - onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined} {...companySelectionProps} /> @@ -161,17 +125,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa - - ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index e3e7cfa5..b8ad2a9c 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,13 +1,9 @@ -import FilterListIcon from "@mui/icons-material/FilterList"; -import { - Button, - Chip, - CircularProgress, - Stack, - TablePagination, - TextField, - Typography, -} from "@mui/material"; +import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; +import { Plus, SlidersHorizontal } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; +import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { globalCount: number; @@ -33,52 +29,46 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - - Vendors / Technicians - - - {globalCount} vendor entries · {activeCount} active - - {isFetching && !isLoading && } - - - - onSearchChange(e.target.value)} - placeholder="Search name, company, trade..." - className="min-w-[260px]" - /> - - - - + + } + /> ); } @@ -90,16 +80,21 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; + resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ + chips, + resultCount, + onClearAll, +}: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - Applied: - + + {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "vendor entry" : "vendor entries"} + {totalCount === 1 ? "technician" : "technicians"} {isFiltered && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[12, 24, 48]} - /> + + + {activeCount} active · {inactiveCount} inactive + + {totalCount > pageSize && ( + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[24, 48, 96]} + /> + )} ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 802fff8a..db588cff 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,11 +1,10 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Chip, + Box, CircularProgress, IconButton, Link, @@ -18,8 +17,8 @@ import { TableHead, TableRow, Tooltip, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -30,9 +29,6 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", - "Status", - "View", - "Edit", ] as const; function getInitials(name: string): string { @@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } +function VendorStatus({ isActive }: { isActive: boolean }) { + return ( + + + {isActive ? "Active" : "Inactive"} + + ); +} + interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { - const navigate = useNavigate(); +function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo onKeyDown={handleKeyDown} > - {row.companyName || "—"} + + {row.companyName || "—"} + - + {getInitials(row.contactName)} - {row.contactName || "—"} + + {row.contactName || "—"} + - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && } + {extraTrades > 0 && ( + + +{extraTrades} + + )} )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + { event.stopPropagation(); - navigate( - row.id != null - ? `/vendors/edit/${row.id}` - : `/vendors/company/${row.companyId ?? ""}`, - ); + onOpenEdit(row); }} > - {hasVendorId && row.isActive && ( - - { - event.stopPropagation(); - onOpenDeactivation(row); - }} - > - - - - )} @@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo interface VendorsTableProps { rows: VendorListItem[]; + isActive: boolean; + isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } export function VendorsTable({ rows, + isActive, + isFiltered, isLoading, showError, onOpenDetail, - onOpenDeactivation, + onOpenEdit, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; + const columnCount = TABLE_COLUMNS.length + 2; return ( - - + +
    + + + + + + + + + + + {TABLE_COLUMNS.map((column) => ( - + {column} ))} + Status + {isLoading ? ( - + ) : showEmptyRow ? ( - - - No vendors match the current filters. - + + + + + {`No ${isActive ? "active" : "inactive"} technicians found`} + + + {isFiltered + ? "Try a different search, or add a new vendor." + : "Add a vendor to get started."} + + ) : ( @@ -252,7 +338,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenDeactivation={onOpenDeactivation} + onOpenEdit={onOpenEdit} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a084b59b..48447ad6 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,16 +23,91 @@ interface AppliedChip { label: string; } +function statusTabLabel(label: string, count: number) { + return ( + + {label} + + {count} + + + ); +} + +function VendorStatusTabs({ + isActive, + activeCount, + inactiveCount, + onChange, +}: { + isActive: boolean; + activeCount: number; + inactiveCount: number; + onChange: (isActive: boolean) => void; +}) { + return ( + onChange(value === "active")} + aria-label="Vendor status" + sx={{ + minHeight: 36, + flexShrink: 0, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + backgroundColor: "var(--color-bg-surface)", + mx: -2, + px: 1.5, + "& .MuiTab-root": { + minHeight: 36, + px: 2, + py: 0, + fontSize: 13, + fontWeight: 500, + textTransform: "none", + }, + "& .MuiTab-root:first-of-type": { width: 102.84375 }, + "& .Mui-selected": { fontWeight: 600 }, + "& .MuiTabs-indicator": { height: 2 }, + }} + > + + + + ); +} + function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); - filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); - filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); + filters.companies.forEach((value) => + chips.push({ category: "companies", value, label: `Company: ${value}` }), + ); + filters.trades.forEach((value) => + chips.push({ category: "trades", value, label: `Trade: ${value}` }), + ); + filters.locations.forEach((value) => + chips.push({ category: "locations", value, label: `Location: ${value}` }), + ); filters.jobBuckets.forEach((value) => - chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + chips.push({ + category: "jobBuckets", + value, + label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, + }), ); return chips; } @@ -42,7 +117,12 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const deactivation = useVendorDeactivation(); + const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); + const closeDetail = () => { + setDetailTarget(null); + setDetailStartsInEdit(false); + }; + const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -61,7 +141,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - - filters.setIsActiveWithReset(value === "active") - } - aria-label="Vendor status" - > - - - + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -102,10 +179,18 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={setDetailTarget} - onOpenDeactivation={deactivation.open} + onOpenDetail={(row) => { + setDetailStartsInEdit(false); + setDetailTarget(row); + }} + onOpenEdit={(row) => { + setDetailStartsInEdit(true); + setDetailTarget(row); + }} /> setDetailTarget(null)} + startInEdit={detailStartsInEdit} + onRequestDeactivation={deactivation.open} + onClose={closeDetail} /> @@ -49,15 +50,31 @@ export function PageHeader({ "m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground", eyebrow && "mt-1", )} + sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }} > {title} - + {subtitle} {Boolean(actions) && ( - + {actions} )} diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts index cdc4b010..37532220 100644 --- a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts +++ b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts @@ -91,11 +91,6 @@ export function useSaveVendorCompanyRoster(): UseMutationResult< ? getSingleStatusOnlyChange(originalRoster, values) : null; if (statusChange) { - if (statusChange.isActive === false) { - throw new Error( - "Use Deactivate to check open work orders before inactivating a technician.", - ); - } const baseRoster = originalRoster as VendorCompanyRoster; await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive }); return { diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index c460388d..2e9f8f1f 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -5,6 +5,7 @@ import { useController } from "react-hook-form"; import { beforeEach, describe, expect, it, vi } from "vitest"; const rosterGet = vi.fn(); +const saveMutate = vi.fn(); vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, @@ -24,8 +25,6 @@ vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ useVendorFacets: () => ({ data: { companies: [], trades: [] } }), })); -const saveMutate = vi.fn(); - vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => { const actual = await vi.importActual< typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") @@ -202,10 +201,47 @@ describe("useVendorRosterForm load-error handling", () => { }); }); +describe("useVendorRosterForm prototype defaults", () => { + it("does not persist the prototype's untouched optional technician card", () => { + const { result } = renderHook( + () => useVendorRosterForm({ mode: "create", startWithTechnician: true }), + { wrapper: makeWrapper(createClient()) }, + ); + + act(() => { + result.current.submit({ + name: "Independent Vendor LLC", + companyPhone: "(314) 555-0199", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, + }, + ], + }); + }); + + expect(saveMutate).toHaveBeenCalledWith( + expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }), + expect.any(Object), + ); + }); +}); + describe("useVendorRosterForm stale-selection handling", () => { beforeEach(() => { rosterGet.mockReset(); - saveMutate.mockReset(); }); it("keeps the newest company roster when two lookups resolve out of order", async () => { @@ -280,97 +316,3 @@ describe("useVendorRosterForm stale-selection handling", () => { expect(result.current.name.field.value).toBe("Draft Vendor"); }); }); - -describe("useVendorRosterForm blank technician filtering", () => { - beforeEach(() => { - rosterGet.mockReset(); - saveMutate.mockReset(); - }); - - it("omits blank new technician rows from the save payload", () => { - const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { - wrapper: makeWrapper(createClient()), - }); - - act(() => { - result.current.submit({ - name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", - email: "dispatch@gateway.test", - address: "", - city: "", - state: "", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - contactName: "Taylor", - phone: "(314) 555-0199", - email: "taylor@gateway.test", - tradeSpecialties: "Plumbing", - isActive: true, - }, - { - contactName: "", - phone: "", - email: "", - tradeSpecialties: "", - isActive: true, - }, - ], - }); - }); - - expect(saveMutate).toHaveBeenCalledTimes(1); - const payload = saveMutate.mock.calls[0]?.[0] as { - values: { technicians: Array<{ contactName: string }> }; - }; - expect(payload.values.technicians).toHaveLength(1); - expect(payload.values.technicians[0]?.contactName).toBe("Taylor"); - }); - - it("keeps existing technicians with an id even when contact fields are blank", () => { - const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { - wrapper: makeWrapper(createClient()), - }); - - act(() => { - result.current.submit({ - name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", - email: "dispatch@gateway.test", - address: "", - city: "", - state: "", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - id: 9, - contactName: "", - phone: "", - email: "", - tradeSpecialties: "", - isActive: true, - }, - ], - }); - }); - - const payload = saveMutate.mock.calls[0]?.[0] as { - values: { technicians: Array<{ id?: number }> }; - }; - expect(payload.values.technicians).toEqual([ - { - id: 9, - contactName: "", - phone: "", - email: "", - tradeSpecialties: "", - isActive: true, - }, - ]); - }); -}); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index a55ddaf0..df329a4a 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,11 +49,11 @@ function rosterWith(technicians: Array>) { }; } -describe("VendorDetailDrawer preferred-contact display", () => { +describe("VendorDetailDrawer selected-technician display", () => { it("renders no preference label when preferredContact is absent", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ - { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + { id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, ]), ); @@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => { withAuth: false, }); - expect(screen.getByText("Adam")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); }); - it("renders the preference label when an explicit preferredContact is present", () => { + it("keeps the retired preferred-contact value out of the detail view", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ { - id: 7, + id: 1, contactName: "Adam", phone: "314-555-0198", preferredContact: "Email", @@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => { withAuth: false, }); - expect(screen.getByText("Prefers Email")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); + expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); + }); + + it("falls back to the clicked row when the roster has no matching stable id", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Adam Whyte", + phone: "999-999-9999", + isActive: true, + totalJobs: 99, + }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument(); + }); + + it("does not expose a status control for a different technician", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Different Technician", + phone: "314-555-0101", + email: "", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 10, + }, + ]), + ); + + renderWithProviders( + , + { route: "/vendors", withAuth: false }, + ); + + expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument(); }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 64c71c69..a5fd7a64 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -1,21 +1,14 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); -const navigate = vi.fn(); - -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router"); - return { ...actual, useNavigate: () => navigate }; -}); - vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); @@ -61,6 +54,32 @@ const inactiveVendor = { isActive: false, }; +const activeRoster: VendorCompanyRoster = { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gatewayplumbing.com", + address: "", + city: "St. Louis", + state: "MO", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + preferredContact: "Email", + tradeSpecialties: "Backflow Preventers", + isActive: true, + totalJobs: 0, + }, + ], +}; + function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, @@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) { } function setupDefaults() { - useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); + useVendorCompanyRoster.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorDeactivationImpact.mockReturnValue({ data: undefined, isLoading: false, @@ -80,7 +105,7 @@ function setupDefaults() { } describe("VendorsListPage", () => { - it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { + it("renders prototype-aligned status and two row actions in both tabs", async () => { setupDefaults(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); @@ -91,11 +116,20 @@ describe("VendorsListPage", () => { expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "View vendor Gateway Plumbing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" })); expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); @@ -114,39 +148,18 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); + expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ - data: { - companyId: "co-1", - rowVersion: "rv-1", - name: "Gateway Plumbing", - companyPhone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - address: "", - city: "St. Louis", - state: "MO", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - id: 1, - contactName: "Adam Whyte", - phone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - preferredContact: "Email", - tradeSpecialties: "Backflow Preventers", - isActive: true, - }, - ], - }, + data: activeRoster, isLoading: false, + isError: false, error: null, + refetch: vi.fn(), }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); @@ -161,11 +174,18 @@ describe("VendorsListPage", () => { row.focus(); await userEvent.keyboard("{Enter}"); - expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); }); it("blocks deactivation when the preflight reports open work orders", async () => { setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorDeactivationImpact.mockReturnValue({ data: { vendorId: 1, @@ -189,7 +209,8 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); expect( screen.getByText(/cannot be deactivated because it still has open work orders/), @@ -199,9 +220,51 @@ describe("VendorsListPage", () => { expect(mutate).not.toHaveBeenCalled(); }); - it("navigates to the vendor edit route when the table Edit control is clicked", async () => { + it("preserves inline edits when deactivation is cancelled", async () => { setupDefaults(); - navigate.mockClear(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useVendorDeactivationImpact.mockReturnValue({ + data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] }, + isLoading: false, + error: null, + }); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + const company = screen.getByRole("textbox", { name: "Company" }); + await userEvent.clear(company); + await userEvent.type(company, "Draft Company Name"); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await userEvent.click( + within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { + name: "Cancel", + }), + ); + + expect(company).toBeInTheDocument(); + expect(company).toHaveValue("Draft Company Name"); + }); + + it("opens inline edit when the table Edit control is clicked", async () => { + setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([activeVendor], 1) : result([], 0); @@ -211,16 +274,20 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); - expect( - screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); }); - it("navigates to the company route when editing a vendor without a vendor id", async () => { + it("opens inline company edit when the row has no vendor id", async () => { setupDefaults(); - navigate.mockClear(); const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; + useVendorCompanyRoster.mockReturnValue({ + data: { ...activeRoster, companyId: "co-9", technicians: [] }, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); @@ -230,6 +297,7 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); + expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); }); }); diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts index 9c61e64f..f0547378 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -1,32 +1,7 @@ -import { createElement, type ReactNode } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - getSingleStatusOnlyChange, - useSaveVendorCompanyRoster, -} from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import { describe, expect, it } from "vitest"; +import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; -const updateVendor = vi.fn(); - -vi.mock("@/domain/vendors/api/vendors-api", () => ({ - vendorsApi: { - update: (...args: unknown[]) => updateVendor(...args), - }, -})); - -vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ - vendorCompanyRosterApi: { - update: vi.fn(), - create: vi.fn(), - }, -})); - -vi.mock("react-toastify", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})); - const roster: VendorCompanyRoster = { companyId: 10, rowVersion: "rv-1", @@ -94,49 +69,3 @@ describe("getSingleStatusOnlyChange", () => { ).toBeNull(); }); }); - -describe("useSaveVendorCompanyRoster status-only deactivation gate", () => { - beforeEach(() => { - updateVendor.mockReset(); - updateVendor.mockResolvedValue(undefined); - }); - - it("rejects sparse Active→Inactive updates and still allows reactivation", async () => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const wrapper = ({ children }: { children: ReactNode }) => - createElement(QueryClientProvider, { client }, children); - - const { result } = renderHook(() => useSaveVendorCompanyRoster(), { wrapper }); - - const activeRoster: VendorCompanyRoster = { - ...roster, - technicians: [{ ...roster.technicians[0], isActive: true }], - }; - - await expect( - result.current.mutateAsync({ - mode: "update", - values: { - ...values, - technicians: [{ ...values.technicians[0], isActive: false }], - }, - companyId: 10, - rowVersion: "rv-1", - originalRoster: activeRoster, - }), - ).rejects.toThrow("Use Deactivate to check open work orders before inactivating a technician."); - expect(updateVendor).not.toHaveBeenCalled(); - - await result.current.mutateAsync({ - mode: "update", - values: { - ...values, - technicians: [{ ...values.technicians[0], isActive: true }], - }, - companyId: 10, - rowVersion: "rv-1", - originalRoster: roster, - }); - expect(updateVendor).toHaveBeenCalledWith(7, { isActive: true }); - }); -});