diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index c328af9a..f5bf551a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index c05f2378..1a5be82a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 54c553c4..53ad9890 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 6c1ca3b4..888665b5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index f5948c0d..f453475d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index a1509c39..bc5a0bc2 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index c3e9be5b..9c1201a3 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 15eea83d..9ccc65ca 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index 901b2ab5..50f03dbf 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index a2eeb471..45c3c047 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index 52d00495..ffa12f37 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index e64d4c12..b21a8ded 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index f07dbc15..907f118a 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 7af374c4..97a77e79 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index bf2ebed4..d2e41781 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 0c225e74..13e7ffe3 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 7c838f06..27469fe4 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -29,6 +29,8 @@ const activeVendors = [ TotalJobs: 72, IsActive: true, PreferredContact: "Email", + AreaId: 1 as number | null, + AreaName: "East", }, { Id: 2, @@ -47,9 +49,16 @@ const activeVendors = [ 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], @@ -97,6 +106,7 @@ async function mockVendorApi( listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean; + groupedDirectory?: boolean; legacyGateway?: boolean; longText?: boolean; } = {}, @@ -134,6 +144,7 @@ async function mockVendorApi( 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" }, @@ -147,7 +158,7 @@ async function mockVendorApi( }, }), ); - await page.route("**/api/Vendor/GetVendorList*", async (route) => { + 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") { @@ -159,17 +170,54 @@ async function mockVendorApi( 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), + (!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 ? filtered.slice(0, 1) : filtered, + Data: isCount ? responseRows.slice(0, 1) : responseRows, TotalCount: filtered.length, PageNumber: 1, PageSize: Number(url.searchParams.get("pageSize") ?? 12), @@ -213,6 +261,8 @@ async function mockVendorApi( zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, + areaId: anchor.AreaId, + areaName: anchor.AreaName, technicians: [ ...vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) @@ -276,6 +326,8 @@ async function mockVendorApi( 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) => ({ @@ -367,6 +419,44 @@ async function mockVendorApi( } 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, }) => { @@ -381,21 +471,25 @@ test.describe("Vendor directory prototype parity", () => { 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.getByText("2 active companies").first()).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", ); for (const heading of [ "Company", - "Technician", - "Location", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", + "Service Area", "Trade", - "Phone", - "Email", + "Technician", + "Technician Phone", "Total Jobs", ]) { - await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); + 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(); @@ -403,10 +497,11 @@ test.describe("Vendor directory prototype parity", () => { 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("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(); @@ -419,13 +514,13 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("dialog")).toHaveCount(0); await tradeBadge.press("Enter"); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByText("2 active technicians")).toBeVisible(); + 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 technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); @@ -442,8 +537,8 @@ test.describe("Vendor directory prototype parity", () => { 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 technician · filtered by")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).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", @@ -451,7 +546,7 @@ test.describe("Vendor directory prototype parity", () => { ).toBe(true); await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No inactive technicians found")).toBeVisible(); + 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(); @@ -466,10 +561,10 @@ test.describe("Vendor directory prototype parity", () => { const row = page.locator("tbody tr").first(); const fields = [ { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, - { cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, - { cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address }, - { cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone }, - { cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email }, + { 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) { @@ -603,7 +698,8 @@ test.describe("Vendor directory prototype parity", () => { "1 Market St", ); await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis"); - await expect(page.getByLabel("State (required)")).toHaveValue("MO"); + 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); @@ -676,6 +772,55 @@ test.describe("Vendor directory prototype parity", () => { 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"); @@ -719,11 +864,14 @@ test.describe("Vendor directory prototype parity", () => { // 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("MO"); + 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 }) => { diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index 73ddbc30..005c9da0 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -119,7 +119,7 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = " return; } - if (url.pathname.endsWith("/api/Vendor/GetVendorList")) { + if (url.pathname.endsWith("/api/Vendor/GetVendorDirectoryList")) { const isCount = url.searchParams.get("pageSize") === "1"; if (mode === "error" && !isCount) { await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); @@ -243,8 +243,12 @@ test.describe("Vendor deterministic pixel regression", () => { test("empty", async ({ page }) => { await openVendorPage(page, "empty"); - await expect(page.getByText("No active technicians found")).toBeVisible(); + const emptyState = page.getByText("No active companies found"); + await expect(emptyState).toBeVisible(); await expectStableScreenshot(page, "vendor-empty.png"); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(emptyState).toBeInViewport(); }); test("error", async ({ page }) => { diff --git a/e2e/work-orders/due-date-schedule-floor.spec.ts b/e2e/work-orders/due-date-schedule-floor.spec.ts new file mode 100644 index 00000000..afd181e9 --- /dev/null +++ b/e2e/work-orders/due-date-schedule-floor.spec.ts @@ -0,0 +1,80 @@ +import { expect, test, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "due-date-floor-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +test.describe("Due Date floor in the Create WO wizard (SH-356)", () => { + test.setTimeout(60_000); + + test("disables days before Schedule On and clears Due Date when Schedule On passes it", async ({ + page, + }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + if (route.request().method() !== "GET") { + await fulfillJson(route, {}, 500); + return; + } + await fulfillJson(route, { + data: [], + items: [], + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 0, total: 0 }, + unscheduled: [], + scheduled: [], + }); + }, + ); + + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({ + timeout: 20_000, + }); + + await page.getByRole("button", { name: /Pick a date/ }).click(); + const dateTab = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTab.isVisible()) { + await dateTab.click(); + } + await page.getByRole("button", { name: "20", exact: true }).click(); + + await page.getByRole("button", { name: /No due date/ }).click(); + await expect(page.getByRole("button", { name: "19", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "20", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "21", exact: true }).click(); + await expect(page.getByRole("button", { name: /No due date/ })).toHaveCount(0); + + await page.getByRole("button", { name: /Aug 20/ }).click(); + const dateTabAgain = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTabAgain.isVisible()) { + await dateTabAgain.click(); + } + await page.getByRole("button", { name: "25", exact: true }).click(); + + await expect(page.getByRole("button", { name: /No due date/ })).toBeVisible(); + }); +}); diff --git a/e2e/work-orders/wo-number-sh-placeholder.spec.ts b/e2e/work-orders/wo-number-sh-placeholder.spec.ts new file mode 100644 index 00000000..f2211583 --- /dev/null +++ b/e2e/work-orders/wo-number-sh-placeholder.spec.ts @@ -0,0 +1,121 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-number-sh-placeholder-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function hideQueryDevtools(page: Page) { + await page.addStyleTag({ + content: + ".tsqd-parent-container { display: none !important; pointer-events: none !important; }", + }); + await page.locator(".tsqd-parent-container").evaluateAll((nodes) => { + for (const node of nodes) { + node.remove(); + } + }); +} + +const REAL_ROW = { + id: 1000, + woNumber: "12345678901", + workOrderType: 2, + siteCode: "DAL1", + lifecycleStatusLabel: "Scheduled", + scheduledDate: "2026-08-19", + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", +}; + +async function mockBoard(page: Page, patches: unknown[]) { + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const request = route.request(); + const url = request.url(); + if (request.method() !== "GET") { + patches.push({ url, body: request.postDataJSON() }); + await fulfillJson(route, {}, 500); + return; + } + if (url.includes("workorders/board/search")) { + await fulfillJson(route, { items: [REAL_ROW], totalCount: 1, page: 0, pageSize: 50 }); + return; + } + if (url.includes("workorders/board")) { + await fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [REAL_ROW], + }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("inline WO number SH placeholder", () => { + test.setTimeout(60_000); + + test.beforeEach(async ({ page }) => { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + }, TEST_USER); + }); + + test("keeps a typed SH number on a new inline row and blocks downgrading a saved real number", async ({ + page, + }) => { + const mutations: unknown[] = []; + await mockBoard(page, mutations); + await page.goto("/workorders"); + await hideQueryDevtools(page); + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText("12345678901").first()).toBeVisible(); + + await page + .getByRole("button", { name: /Add WO/ }) + .first() + .click(); + await page + .getByText(/^Insert WO #/) + .first() + .click(); + const draftInput = page.getByLabel("Work order number"); + await draftInput.fill("SH00001"); + await draftInput.press("Enter"); + await expect(draftInput).toHaveCount(0); + await expect(page.getByText("SH00001", { exact: true })).toBeVisible(); + await expect(page.getByText(/must be 11 digits/)).toHaveCount(0); + + await page.getByText("12345678901", { exact: true }).first().click(); + const savedInput = page.getByLabel("Work order number"); + await savedInput.fill("SH00009"); + await savedInput.press("Enter"); + await expect( + page.getByText("Cannot replace an APM work order number with an internal number"), + ).toBeVisible(); + await expect(page.getByText("12345678901", { exact: true }).first()).toBeVisible(); + expect(mutations).toEqual([]); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 37db2b85..0b1eb129 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -125,6 +125,7 @@ export const API_PATHS = { }, vendor: { getList: "Vendor/GetVendorList", + getDirectoryList: "Vendor/GetVendorDirectoryList", getById: "Vendor/GetById", create: "Vendor/Create", update: "Vendor/Update", @@ -154,6 +155,9 @@ export const API_PATHS = { update: "DropdownOptions/Update", delete: "DropdownOptions", }, + services: { + list: "services", + }, user: { list: "User", }, diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx new file mode 100644 index 00000000..9053cbe4 --- /dev/null +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -0,0 +1,237 @@ +import { useCallback, useRef, useState, type KeyboardEvent } from "react"; +import { + useFieldArray, + type Control, + type FieldErrors, + type UseFormRegister, +} from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + Box, + Button, + Chip, + IconButton, + Paper, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { + LocationContactFormValues, + LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; + +interface ContactRowProps { + canRemove: boolean; + dragIndex: number | null; + errors: FieldErrors; + fieldId: string; + index: number; + isLast: boolean; + register: UseFormRegister; + onDragEnd: () => void; + onDragStart: (index: number) => void; + onDrop: (index: number) => void; + onRemove: (index: number) => void; + onKeyboardMove: (from: number, to: number) => void; +} + +function ContactRow({ + canRemove, + dragIndex, + errors, + fieldId, + index, + isLast, + register, + onDragEnd, + onDragStart, + onDrop, + onRemove, + onKeyboardMove, +}: ContactRowProps) { + const handleRef = useRef(null); + const isDragging = dragIndex === index; + const isDropTarget = dragIndex !== null && dragIndex !== index; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowUp" && index > 0) { + event.preventDefault(); + onKeyboardMove(index, index - 1); + } + if (event.key === "ArrowDown" && !isLast) { + event.preventDefault(); + onKeyboardMove(index, index + 1); + } + }; + + return ( + { + if (dragIndex !== null) { + event.preventDefault(); + } + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(index); + }} + className="transition-opacity" + sx={{ opacity: isDragging ? 0.5 : 1, borderStyle: isDropTarget ? "dashed" : "solid" }} + > + + + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(index)); + onDragStart(index); + }} + onDragEnd={onDragEnd} + onKeyDown={handleKeyDown} + sx={{ cursor: "grab", display: "inline-flex", color: "text.secondary" }} + > + + + + {index === 0 && } + + + + + onRemove(index)} + > + + + + + + + ); +} + +interface LocationContactsFieldArrayProps { + control: Control; + errors: FieldErrors; + register: UseFormRegister; +} + +export function LocationContactsFieldArray({ + control, + errors, + register, +}: LocationContactsFieldArrayProps) { + const { fields, append, remove, move } = useFieldArray({ + control, + name: "contacts", + keyName: "fieldKey", + }); + const [dragIndex, setDragIndex] = useState(null); + const rootRef = useRef(null); + + const focusHandle = useCallback((fieldId: string) => { + requestAnimationFrame(() => { + const handle = rootRef.current?.querySelector(`[data-field-id="${fieldId}"]`); + handle?.focus(); + }); + }, []); + + const handleKeyboardMove = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= fields.length) { + return; + } + const fieldId = fields[from]?.fieldKey; + move(from, to); + if (fieldId) { + focusHandle(fieldId); + } + }, + [fields, move, focusHandle], + ); + + const handleDrop = useCallback( + (index: number) => { + if (dragIndex !== null && dragIndex !== index) { + move(dragIndex, index); + } + setDragIndex(null); + }, + [dragIndex, move], + ); + + return ( + + + + Contacts + + + + + {fields.map((field, index) => ( + 1} + dragIndex={dragIndex} + errors={errors.contacts?.[index] ?? {}} + fieldId={field.fieldKey} + index={index} + isLast={index === fields.length - 1} + register={register} + onDragEnd={() => setDragIndex(null)} + onDragStart={setDragIndex} + onDrop={handleDrop} + onRemove={remove} + onKeyboardMove={handleKeyboardMove} + /> + ))} + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx index c1f2fb1c..39f22296 100644 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ b/src/app/(protected)/locations/_components/location-form-fields.tsx @@ -12,6 +12,7 @@ import { import type { AccountListItem } from "@/domain/accounts/types/account"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { LocationContactsFieldArray } from "./location-contacts-field-array"; import { StateSelect } from "./state-select"; interface SharedFieldsProps { @@ -109,24 +110,7 @@ export function LocationContactFields({ control, errors, register }: SharedField Contact Information - - - - + 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }], contactEmail: location.contactEmail ?? "", status: (location.status as LocationFormValues["status"]) || "Active", }); diff --git a/src/app/(protected)/locations/_components/locations-contacts-cell.tsx b/src/app/(protected)/locations/_components/locations-contacts-cell.tsx new file mode 100644 index 00000000..60a15f27 --- /dev/null +++ b/src/app/(protected)/locations/_components/locations-contacts-cell.tsx @@ -0,0 +1,46 @@ +import { Chip, Stack, Tooltip, Typography } from "@mui/material"; +import type { LocationListItem } from "@/domain/locations/types/location"; + +/** Main contact name with a `+N` chip; hover/focus lists the remaining contact names. */ +export function LocationsContactsCell({ row }: { row: LocationListItem }) { + const contacts = row.contacts ?? []; + const mainName = row.contact || contacts[0]?.name || ""; + const remainingNames = contacts + .slice(1) + .map((contact) => contact.name.trim()) + .filter((name) => name !== ""); + + if (mainName === "") { + return ( + + — + + ); + } + + if (remainingNames.length === 0) { + return ( + + {mainName} + + ); + } + + const remainingLabel = remainingNames.join(", "); + + return ( + + + {mainName} + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index 030031d7..bfd36118 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -15,6 +15,7 @@ import { Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; +import { LocationsContactsCell } from "./locations-contacts-cell"; import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { @@ -74,7 +75,9 @@ export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsT {row.phone || "—"} - {row.contact || "—"} + + + {row.status || "—"} 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 fdc90685..0b56ddf3 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -16,6 +16,7 @@ export interface VendorListState { isActive: boolean; companies: string[]; trades: string[]; + areas: string[]; locations: string[]; jobBuckets: string[]; }; @@ -45,6 +46,7 @@ export function useVendorListState(): VendorListState { isActive, companies: appliedFilters.companies, trades: appliedFilters.trades, + areas: appliedFilters.areas, locations: appliedFilters.locations, jobBuckets: appliedFilters.jobBuckets, }), 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 aaf1b606..0beb87fe 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useContext, useMemo, useState } from "react"; import { toast } from "react-toastify"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { @@ -15,6 +15,8 @@ import { type EditedCompanyFields, } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; +import { AuthContext } from "@/providers/auth-context"; +import { isAdminUser } from "@/lib/auth/user-utils"; const VENDOR_ROSTER_NO_CHANGES_MESSAGE = "No changes to save. Enter technician details or update a company field."; @@ -28,6 +30,7 @@ const EDITABLE_COMPANY_FIELDS = [ "state", "zip", "notes", + "areaId", ] as const; /** @@ -48,10 +51,21 @@ import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; import type { VendorCompanyRoster, + VendorFacetArea, VendorFacetCompany, VendorRosterConflict, } from "@/domain/vendors/types/vendor"; +export interface VendorRosterAreaFieldProps { + areaOptions: VendorFacetArea[]; + currentArea: VendorFacetArea | null; + canAssignArea: boolean; +} + +function toCurrentArea(roster: VendorCompanyRoster | null | undefined): VendorFacetArea | null { + return roster && roster.areaId !== "" ? { id: roster.areaId, name: roster.areaName } : null; +} + function withoutBlankNewTechnicians( values: VendorCompanyRosterFormValues, ): VendorCompanyRosterFormValues { @@ -96,6 +110,7 @@ export interface VendorRosterForm { companies: VendorFacetCompany[]; trades: string[]; tradesLoading: boolean; + areaField: VendorRosterAreaFieldProps; selectedCompanyId: string | number | null; selectCompany: (company: VendorFacetCompany | null) => Promise; clearSelectedCompany: (nextName?: string) => void; @@ -116,6 +131,8 @@ export function useVendorRosterForm({ ); const { data: facets, isLoading: facetsLoading } = useVendorFacets(); const save = useSaveVendorCompanyRoster(); + // Area assignment is Admin-only; the API enforces it and the field mirrors it. + const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles); const [conflict, setConflict] = useState(null); const routeRoster = mode === "update" ? query.data : undefined; @@ -151,6 +168,14 @@ export function useVendorRosterForm({ clearConflict, }); const { resetSelection } = selection; + const areaField = useMemo( + () => ({ + areaOptions: facets?.areas ?? [], + currentArea: toCurrentArea(routeRoster ?? selection.selectedRoster), + canAssignArea, + }), + [canAssignArea, facets, routeRoster, selection.selectedRoster], + ); const isValid = useMemo(() => { if (vendorCompanyRosterSchema.safeParse(watched).success) return true; @@ -238,6 +263,7 @@ export function useVendorRosterForm({ companies, trades, tradesLoading: facetsLoading, + areaField, selectedCompanyId: selection.selectedCompanyId, selectCompany: selection.selectCompany, clearSelectedCompany: selection.clearSelectedCompany, diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index a472546e..f0893e32 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -43,6 +43,7 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo // The deprecated stored Maps URL is intentionally not seeded: the form has // no input for it, and carrying it would re-emit a legacy value on write. notes: roster.notes, + areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ id: technician.id, contactName: technician.contactName, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 55e3506d..cf8dcb8e 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -77,6 +77,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { selectedCompanyId={form.selectedCompanyId} onSelectCompany={form.selectCompany} onClearSelectedCompany={form.clearSelectedCompany} + {...form.areaField} /> diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 072bddb7..2a69b204 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -155,6 +155,7 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) { + ); @@ -336,6 +337,7 @@ function DrawerEditor({ tradeOptions={form.trades} tradeOptionsLoading={form.tradesLoading} showTechnicianStatus={false} + {...form.areaField} /> {selectedIndex >= 0 && ( <> diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx index c9be7d45..aac0b212 100644 --- a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -3,6 +3,7 @@ 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 MapOutlinedIcon from "@mui/icons-material/MapOutlined"; import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; import { Accordion, @@ -17,13 +18,14 @@ import { TextField, Typography, } from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, +import { + type VendorFacetArea, + type VendorFacets, + type VendorFacetCompany, + type VendorFacetJobBucket, + type VendorFacetLocation, } from "@/domain/vendors/types/vendor"; -import type { VendorFilters } from "./vendor-filters"; +import { serviceAreaFilterOptions, type VendorFilters } from "./vendor-filters"; type DraftSetter = Dispatch>; @@ -156,6 +158,33 @@ interface FilterProps { setDraft: DraftSetter; } +function ServiceAreaFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={options.filter((option) => draft.areas.includes(option.id))} + onChange={(_event, value) => + setDraft((current) => ({ ...current, areas: value.map((entry) => entry.id) })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + function LocationFilter({ options, draft, setDraft }: FilterProps) { return ( }> @@ -245,6 +274,11 @@ export function VendorFilterOptions({ <> + diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index b18c568c..1aae6720 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -31,7 +31,11 @@ export function VendorFiltersDrawer({ }, [open, appliedFilters]); const activeCount = - draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + draft.companies.length + + draft.trades.length + + draft.areas.length + + draft.locations.length + + draft.jobBuckets.length; const handleClear = () => { setDraft(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/vendor-filters.ts b/src/app/(protected)/vendors/_components/vendor-filters.ts index 3c236daa..2bd23452 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters.ts +++ b/src/app/(protected)/vendors/_components/vendor-filters.ts @@ -1,6 +1,10 @@ +import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor"; + export interface VendorFilters { companies: string[]; trades: string[]; + /** Stable Area ids or VENDOR_AREA_UNASSIGNED; OR within the facet. */ + areas: string[]; locations: string[]; jobBuckets: string[]; } @@ -8,6 +12,17 @@ export interface VendorFilters { export const emptyVendorFilters: VendorFilters = { companies: [], trades: [], + areas: [], locations: [], jobBuckets: [], }; + +const UNASSIGNED_AREA_OPTION: VendorFacetArea = { id: VENDOR_AREA_UNASSIGNED, name: "Unassigned" }; + +/** Unassigned first, then the active Area catalogue A–Z (case-insensitive). */ +export function serviceAreaFilterOptions(areas: VendorFacetArea[]): VendorFacetArea[] { + const sorted = [...areas].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + return [UNASSIGNED_AREA_OPTION, ...sorted]; +} 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 cf23d357..d5094cf0 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -30,8 +30,11 @@ import { type RosterTechnicianValues, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { VendorFacetArea, VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import { normalizeStateCode } from "@/data/areas"; import { VendorCompanyContactFields } from "./vendor-company-contact-fields"; +import { VendorServiceAreaField } from "./vendor-service-area-field"; +import { VendorStateSelectField } from "./vendor-state-select-field"; interface VendorRosterFormFieldsProps { control: Control; @@ -43,6 +46,9 @@ interface VendorRosterFormFieldsProps { onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; showTechnicianStatus?: boolean; + areaOptions?: VendorFacetArea[]; + currentArea?: VendorFacetArea | null; + canAssignArea?: boolean; } function CompanyNameField({ @@ -136,6 +142,9 @@ function CompanyFields({ selectedCompanyId, onSelectCompany, onClearSelectedCompany, + areaOptions = [], + currentArea = null, + canAssignArea = false, }: VendorRosterFormFieldsProps) { const notes = useWatch({ control, name: "notes" }) ?? ""; const { field: streetField } = useController({ control, name: "address" }); @@ -163,7 +172,7 @@ function CompanyFields({ // revalidates — a plain assignment would be discarded on save. streetField.onChange(parts.street); cityField.onChange(parts.city); - stateField.onChange(parts.state); + stateField.onChange(normalizeStateCode(parts.state) ?? parts.state); }} label="Street Address (required)" required @@ -183,15 +192,13 @@ function CompanyFields({ fullWidth slotProps={{ htmlInput: { required: true } }} /> - )} /> + ); diff --git a/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx b/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx new file mode 100644 index 00000000..d8171aa0 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx @@ -0,0 +1,58 @@ +import { Controller, type Control } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { VendorFacetArea } from "@/domain/vendors/types/vendor"; + +interface VendorServiceAreaFieldProps { + control: Control; + areaOptions: VendorFacetArea[]; + currentArea?: VendorFacetArea | null; + canAssign: boolean; +} + +export const SERVICE_AREA_ADMIN_ONLY_MESSAGE = "Only administrators can assign a Service Area."; + +/** + * Zero or one Service Area per vendor company, chosen from the Area + * catalogue by stable id. Clearing the selection leaves the company Unassigned. + * The saved Area stays visible when it is no longer in the active catalogue. + */ +export function VendorServiceAreaField({ + control, + areaOptions, + currentArea, + canAssign, +}: VendorServiceAreaFieldProps) { + const options = + currentArea && !areaOptions.some((area) => area.id === currentArea.id) + ? [...areaOptions, currentArea] + : areaOptions; + + return ( + ( + + options={options} + value={options.find((area) => area.id === field.value) ?? null} + onChange={(_event, option) => field.onChange(option?.id ?? "")} + onBlur={field.onBlur} + getOptionLabel={(option) => option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + disabled={!canAssign} + fullWidth + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx b/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx new file mode 100644 index 00000000..db9e0329 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx @@ -0,0 +1,57 @@ +import type { Ref } from "react"; +import { Autocomplete, TextField } from "@mui/material"; +import { + US_STATE_CODES, + formatStateOptionLabel, + normalizeStateCode, + type StateCode, +} from "@/data/areas"; + +interface VendorStateSelectFieldProps { + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + inputRef?: Ref; + error?: boolean; + helperText?: string; +} + +/** + * Closed, searchable list of the 50 US states for the vendor company. + * Legacy full names are shown as their canonical code. Service Area is chosen + * independently, so no Area is derived from the selected State here. + */ +export function VendorStateSelectField({ + value, + onChange, + onBlur, + inputRef, + error, + helperText, +}: VendorStateSelectFieldProps) { + const selected: StateCode | null = normalizeStateCode(value); + return ( + + options={US_STATE_CODES} + value={selected} + onChange={(_event, option) => onChange(option ?? "")} + onBlur={onBlur} + getOptionLabel={formatStateOptionLabel} + fullWidth + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-table-row.tsx b/src/app/(protected)/vendors/_components/vendor-table-row.tsx new file mode 100644 index 00000000..41e2b740 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-table-row.tsx @@ -0,0 +1,340 @@ +import { type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Avatar, Box, IconButton, Link, Stack, TableCell, TableRow, Tooltip } from "@mui/material"; +import { VendorStatusBadge } from "./vendor-status-badge"; +import { Text } from "@/components/ui/text"; +import type { VendorDirectoryTechnician, VendorListItem } from "@/domain/vendors/types/vendor"; + +function stopPropagation(event: MouseEvent): void { + event.stopPropagation(); +} +function stopBadgeKeyDown(event: KeyboardEvent): void { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); +} + +const truncatedTextSx = { + display: "block", + minWidth: 0, + maxWidth: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +function TruncatedText({ + value, + testId, + focusable = false, + href, + variant, +}: { + value: string; + testId: string; + focusable?: boolean; + href?: string; + variant?: "description"; +}) { + const hasValue = value !== "—"; + const content = href ? ( + + {value} + + ) : variant ? ( + + {value} + + ) : ( + + {value} + + ); + return ( + + {content} + + ); +} + +function OverflowBadge({ + count, + label, + kind, +}: { + count: number; + label: string; + kind: "trade" | "technician"; +}) { + const noun = count === 1 ? kind : `${kind}s`; + const ariaLabel = `+${count} more ${noun}${kind === "trade" ? `: ${label}` : ""}`; + return ( + + + +{count} + + + ); +} + +function CellText({ + value, + testId, + href, + focusable, +}: { + value: string; + testId: string; + href?: string; + focusable?: boolean; +}) { + return ( + + + + ); +} + +function CompanyCells({ row }: { row: VendorListItem }) { + const phone = row.companyPhone.trim(); + const email = row.email.trim(); + return ( + <> + + + + + + + + + + + ); +} + +function orderedTechnicians(row: VendorListItem): VendorDirectoryTechnician[] { + const technicians = row.technicians ?? []; + const primaryIndex = + row.id == null ? -1 : technicians.findIndex((item) => String(item.id) === String(row.id)); + if (primaryIndex <= 0) return technicians; + return [technicians[primaryIndex]!, ...technicians.filter((_, index) => index !== primaryIndex)]; +} + +function primaryTechnician(row: VendorListItem, technicians: VendorDirectoryTechnician[]) { + return ( + technicians[0] ?? { + id: row.id ?? "", + contactName: row.contactName, + email: "", + phone: row.phone, + preferredContact: row.preferredContact, + tradeSpecialties: row.tradeSpecialties, + isActive: row.isActive, + totalJobs: row.totalJobs, + } + ); +} + +function TradeCell({ value }: { value: string }) { + const trades = value + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); + const omitted = trades.slice(1); + return ( + + {trades.length === 0 ? ( + "—" + ) : ( + + {trades[0]} + {omitted.length > 0 && ( + + )} + + )} + + ); +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + return `${parts[0]?.[0] ?? ""}${parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""}`.toUpperCase(); +} + +function TechnicianCells({ row }: { row: VendorListItem }) { + const technicians = orderedTechnicians(row); + const primary = primaryTechnician(row, technicians); + const omitted = technicians.slice(1); + const phone = primary.phone.trim(); + return ( + <> + + + + {getInitials(primary.contactName)} + + + {omitted.length > 0 && ( + item.contactName || "Unnamed technician").join(", ")} + kind="technician" + /> + )} + + + + + ); +} + +function RowActions({ + row, + onOpenDetail, + onOpenEdit, +}: { + row: VendorListItem; + onOpenDetail: () => void; + onOpenEdit: () => void; +}) { + const action = (label: string, icon: ReactNode, handler: () => void) => ( + + { + event.stopPropagation(); + handler(); + }} + > + {icon} + + + ); + return ( + + + {action("View", , onOpenDetail)} + {action("Edit", , onOpenEdit)} + + + ); +} + +export function VendorTableRow({ + row, + onOpenDetail, + onOpenEdit, +}: { + row: VendorListItem; + onOpenDetail: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; +}) { + const openDetail = () => onOpenDetail(row); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof Element && event.target.closest("a, button")) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + openDetail(); + }; + return ( + + + + + {row.totalJobs ?? 0} + + + + + + onOpenEdit(row)} /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index b8ad2a9c..9e61d66e 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -6,7 +6,6 @@ import { Text } from "@/components/ui/text"; import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { - globalCount: number; activeCount: number; isFetching: boolean; isLoading: boolean; @@ -18,7 +17,6 @@ interface VendorsListToolbarProps { } export function VendorsListToolbar({ - globalCount, activeCount, isFetching, isLoading, @@ -33,7 +31,7 @@ export function VendorsListToolbar({ surface className="-mx-4 -mt-4" title="Vendors / Technicians" - subtitle={`${globalCount} technicians · ${activeCount} active`} + subtitle={`${activeCount} active ${activeCount === 1 ? "company" : "companies"}`} actions={ <> - {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {resultCount} {resultCount === 1 ? "company" : "companies"} · filtered by {chips.map((chip) => ( - {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} + {totalCount} {isActive ? "active" : "inactive"} {totalCount === 1 ? "company" : "companies"} {isFiltered && " · filtered"} diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 3e851435..92b13609 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,13 +1,6 @@ -import { type KeyboardEvent, type MouseEvent } from "react"; import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { - Avatar, - Box, CircularProgress, - IconButton, - Link, Paper, Stack, Table, @@ -16,297 +9,25 @@ import { TableContainer, TableHead, TableRow, - Tooltip, } from "@mui/material"; -import { VendorStatusBadge } from "./vendor-status-badge"; +import { VendorTableRow } from "./vendor-table-row"; import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ "Company", - "Technician", - "Location", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", + "Service Area", "Trade", - "Phone", - "Email", + "Technician", + "Technician Phone", "Total Jobs", ] as const; -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - -function splitTrades(value: string | undefined): string[] { - return (value ?? "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); -} - -function stopPropagation(event: MouseEvent): void { - event.stopPropagation(); -} - -function stopTradeBadgeKeyDown(event: KeyboardEvent): void { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); -} - -interface TruncatedTextProps { - value: string; - testId: string; - focusable?: boolean; - href?: string; - color?: string; - variant?: "description"; -} - -const truncatedTextSx = { - display: "block", - minWidth: 0, - maxWidth: "100%", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}; - -function TruncatedText({ - value, - testId, - focusable = false, - href, - color = "inherit", - variant, -}: TruncatedTextProps) { - const hasValue = value !== "—"; - const content = href ? ( - - {value} - - ) : variant ? ( - - {value} - - ) : ( - - {value} - - ); - - return ( - - {content} - - ); -} - -function getVendorDisplayValues(row: VendorListItem) { - const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", "); - return { - locationText: cityStateText || row.address.trim() || "—", - phoneText: row.phone.trim() || row.companyPhone.trim(), - }; -} - -interface VendorTableRowProps { - row: VendorListItem; - onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; -} - -function VendorTradeCell({ - primaryTrade, - omittedTrades, -}: { - primaryTrade: string; - omittedTrades: string[]; -}) { - const extraTrades = omittedTrades.length; - return ( - - {primaryTrade === "—" ? ( - "—" - ) : ( - - {primaryTrade} - {extraTrades > 0 && ( - - - +{extraTrades} - - - )} - - )} - - ); -} - -function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { - const trades = splitTrades(row.tradeSpecialties); - const primaryTrade = trades[0] ?? "—"; - const omittedTrades = trades.slice(1); - const { locationText, phoneText } = getVendorDisplayValues(row); - const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - onOpenDetail(row); - }; - - return ( - onOpenDetail(row)} - onKeyDown={handleKeyDown} - > - - - - - - - {getInitials(row.contactName)} - - - - - - - - - - - - - - - {row.totalJobs ?? 0} - - - - - - - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - { - event.stopPropagation(); - onOpenEdit(row); - }} - > - - - - - - - ); -} - interface VendorsTableProps { rows: VendorListItem[]; isActive: boolean; @@ -328,7 +49,6 @@ export function VendorsTable({ }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; const columnCount = TABLE_COLUMNS.length + 2; - return ( - - - - - - - - - + {[190, 160, 220, 220, 140, 100, 150, 190, 220, 170, 110, 120, 130].map((width, index) => ( + + ))} @@ -403,18 +113,21 @@ export function VendorsTable({ {isLoading ? ( - - + + + + ) : showEmptyRow ? ( - - + + - - {`No ${isActive ? "active" : "inactive"} technicians found`} - + {`No ${isActive ? "active" : "inactive"} companies found`} {isFiltered ? "Try a different search, or add a new vendor." diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 48447ad6..9adeb936 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -13,9 +13,9 @@ import { VendorsTable } from "./_components/vendors-table"; import { useVendorDeactivation } from "./_components/use-vendor-deactivation"; import { useVendorListState } from "./_components/use-vendor-list-state"; import type { VendorFilters } from "./_components/vendor-filters"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import { VENDOR_AREA_UNASSIGNED, type VendorListItem } from "@/domain/vendors/types/vendor"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; +import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; interface AppliedChip { category: keyof VendorFilters; @@ -91,6 +91,7 @@ function VendorStatusTabs({ function describeChips( filters: VendorFilters, jobBucketLabels: Map, + areaLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; filters.companies.forEach((value) => @@ -99,6 +100,13 @@ function describeChips( filters.trades.forEach((value) => chips.push({ category: "trades", value, label: `Trade: ${value}` }), ); + filters.areas.forEach((value) => + chips.push({ + category: "areas", + value, + label: `Service Area: ${areaLabels.get(value) ?? value}`, + }), + ); filters.locations.forEach((value) => chips.push({ category: "locations", value, label: `Location: ${value}` }), ); @@ -125,9 +133,17 @@ export default function VendorsListPage() { const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); - const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); - const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); - const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); + const { data, isLoading, isFetching, error } = useVendorDirectoryList(filters.listParams); + const { data: activeCountData } = useVendorDirectoryList({ + isActive: true, + page: 1, + pageSize: 1, + }); + const { data: inactiveCountData } = useVendorDirectoryList({ + isActive: false, + page: 1, + pageSize: 1, + }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -138,12 +154,19 @@ export default function VendorsListPage() { () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), [facets], ); - const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); + const areaLabels = useMemo( + () => + new Map([ + [VENDOR_AREA_UNASSIGNED, "Unassigned"], + ...(facets?.areas ?? []).map((area): [string, string] => [area.id, area.name]), + ]), + [facets], + ); + const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels, areaLabels); return ( - {formatAuditTime(entry.createdAt, isSys)} + {formatAuditTimestamp(entry.createdAt)} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx index 51c262be..2853c205 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx @@ -8,13 +8,29 @@ type SlideOverCompletionActionsProps = { docStatus: CompletionDocStatus; rowVersion?: string | number | null; completionPending: boolean; + /** Completion documents require a selected primary service. */ + hasService: boolean; hasUploadHandler: boolean; pdfInputRef: RefObject; onPdfSelected: (files: FileList | null) => void; onOpenCompletionDoc: () => void; }; -function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) { +function DocStatusButtonContent({ + docStatus, + hasService, +}: { + docStatus: CompletionDocStatus; + hasService: boolean; +}) { + if (!hasService) { + return ( + <> + + Select service + + ); + } if (docStatus === "Yes") { return ( <> @@ -43,6 +59,7 @@ export function SlideOverCompletionActions({ docStatus, rowVersion, completionPending, + hasService, hasUploadHandler, pdfInputRef, onPdfSelected, @@ -83,10 +100,17 @@ export function SlideOverCompletionActions({ + {!hasService && ( + + Select a service before opening the completion document. + + )} ); } diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index 290ec603..cf89d2bf 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -6,7 +6,8 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work- import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; +import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media"; import { formatMediaSizeLimit, isWorkOrderFileTooLarge, @@ -16,10 +17,6 @@ import { SlideOverCompletionMedia } from "./slide-over-completion-media"; import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status"; import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs"; -function isMediaWO(type: WOType | ""): boolean { - return type === "Emergency" || type === "Reactive"; -} - type SlideOverCompletionTabProps = { row: WorkOrderTableRow; isReadOnly: boolean; @@ -56,7 +53,7 @@ export function SlideOverCompletionTab({ const templateUrl = completion?.template?.templateUrl; const signOffAttachment = completion?.signOffAttachment; - if (isMediaWO(row.type)) { + if (hasCompletionMediaGallery(row.type)) { return ( = { other: Paperclip, }; +const FILETYPE_LABEL: Record = { + image: "Photo", + video: "Video", + pdf: "PDF", + doc: "Document", + other: "File", +}; + const EXTRA_DOC_PURPOSES: Array> = [ "Extra", "Aveta", @@ -92,8 +100,13 @@ function ExtraDocRow({ const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra"; return ( -
+
onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, scheduledOn), scheduledOn, scheduleWeekOnly: false, targetWeek: scheduledOn ? "" : draft.targetWeek, @@ -63,6 +68,7 @@ function SlideOverScheduleOnField({ } onSaveWeek={(weekStart) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, weekStart), scheduledOn: "", targetWeek: weekStart, scheduleWeekOnly: true, @@ -74,6 +80,7 @@ function SlideOverScheduleOnField({ } onSaveRange={(start, end) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -175,6 +182,7 @@ export function SlideOverInfoTabEditView({ onDraftChange({ dueDate })} /> diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 346ecb57..5bde5578 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -12,6 +12,7 @@ import type { UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, type MediaSurfaceOwnership, @@ -80,6 +81,7 @@ export function useSlideOverMediaState({ categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const workOrderId = detailForRow?.info.id; + const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type); const [mediaBatchPending, setMediaBatchPending] = useState(false); const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), @@ -116,16 +118,16 @@ export function useSlideOverMediaState({ useMediaUploadSession(detailForRow, persistCompletionUpload); const completionMedia = useMemo( - () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), - [uploads, ownership], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)), + [uploads, ownership, completionHasGallery], ); const extrasUploads = useMemo( () => boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => - isExtrasSurfaceMedia(item, ownership), + isExtrasSurfaceMedia(item, ownership, completionHasGallery), ), - [detailForRow?.media, ownership], + [detailForRow?.media, ownership, completionHasGallery], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 2e6dceb9..0058d354 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -28,6 +28,7 @@ export function buildInlineDraftCreateInput( scheduledOn: draft.scheduledOn, dueDate: draft.dueDate, pm: draft.pm, + serviceId: null, extraServices: draft.extraServices ?? [], avetaRequired: Boolean(draft.avetaRequired), pmNote: draft.serviceNotes ?? "", diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index 12b5a42e..5c76c5a6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -25,6 +25,8 @@ type DateCellProps = { onClear?: () => void; /** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */ srStatusSuffix?: string; + /** Earliest selectable day in the date calendar (SH-356). */ + minDate?: string; }; export function DateCell({ @@ -41,6 +43,7 @@ export function DateCell({ onSaveRange, onClear, srStatusSuffix, + minDate, }: DateCellProps) { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -154,6 +157,7 @@ export function DateCell({ ) : ( { onSave(d); setAnchorEl(null); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx index a47b8e14..3f5d4915 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx @@ -8,6 +8,7 @@ type DueDateCalendarDialogProps = { onOpenChange: (open: boolean) => void; value: string; onSave: (iso: string) => void; + minDate?: string; }; /** @@ -19,6 +20,7 @@ export function DueDateCalendarDialog({ onOpenChange, value, onSave, + minDate, }: DueDateCalendarDialogProps) { return ( { onSave(iso); onOpenChange(false); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx index 1ce90d18..5f09b0a4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx @@ -16,6 +16,8 @@ type DueDateCellProps = { * read by colour alone. */ pastDue?: boolean; + /** Schedule On floor: earlier days are not selectable (SH-356). */ + minDate?: string; onSave: (date: string) => void; }; @@ -29,6 +31,7 @@ export function DueDateCell({ q, highlight, pastDue = false, + minDate, onSave, }: DueDateCellProps) { const [open, setOpen] = useState(false); @@ -40,6 +43,7 @@ export function DueDateCell({ q={q} highlight={highlight} onSave={onSave} + minDate={minDate} srStatusSuffix={pastDue ? "Past due" : undefined} /> ); @@ -87,7 +91,13 @@ export function DueDateCell({ style={{ color: "var(--muted-foreground)" }} /> - + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 369c1101..9273a5e8 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -3,6 +3,12 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; +import { + WO_NUMBER_DOWNGRADE_MESSAGE, + WO_NUMBER_FORMAT_MESSAGE, + isValidBoardWoNumber, + isWoNumberDowngrade, +} from "@/domain/work-orders/utils/wo-number-input"; import { EditableWONumberInput } from "./editable-wo-number-input"; import { HighlightText } from "./highlight-text"; import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills"; @@ -22,8 +28,13 @@ type EditableWONumberCellProps = { onSave: (v: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; locked?: boolean; + /** Empty-state label for create flows; renders with a persistent required asterisk. */ + requiredPlaceholder?: string; }; +/** Inline-create rows pass this id; their number is draft state, never a saved WO number. */ +const UNSAVED_DRAFT_ID = "draft"; + async function resolveDuplicateWoNumber( draft: string, allOrders: WorkOrderTableRow[], @@ -34,6 +45,28 @@ async function resolveDuplicateWoNumber( return remote && String(remote.id) !== String(currentId) ? remote : undefined; } +function EmptyWoNumberLabel({ + value, + q, + requiredPlaceholder, +}: { + value: string; + q?: string; + requiredPlaceholder?: string; +}) { + if (value) { + return ; + } + if (!requiredPlaceholder) { + return "-"; + } + return ( + + {requiredPlaceholder} * + + ); +} + export function EditableWONumberCell({ value, rescheduleCount, @@ -49,6 +82,7 @@ export function EditableWONumberCell({ onSave, onDuplicateFound, locked = false, + requiredPlaceholder, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -63,8 +97,14 @@ export function EditableWONumberCell({ setEditing(false); return; } - if (draft.length !== 11) { - toast("Work order number must be exactly 11 digits"); + if (!isValidBoardWoNumber(draft)) { + toast(WO_NUMBER_FORMAT_MESSAGE); + return; + } + if (String(currentId) !== UNSAVED_DRAFT_ID && isWoNumberDowngrade(value, draft)) { + toast.error(WO_NUMBER_DOWNGRADE_MESSAGE); + setDraft(value); + setEditing(false); return; } setIsChecking(true); @@ -97,14 +137,14 @@ export function EditableWONumberCell({ const handleBlur = () => { if (isChecking) return; - if (draft !== value && draft.length === 11) { + if (draft !== value && isValidBoardWoNumber(draft)) { commit(); return; } setEditing(false); }; - const invalid = editing && draft.length > 0 && draft.length !== 11; + const invalid = editing && draft.length > 0 && !isValidBoardWoNumber(draft); if (locked) { return ( @@ -162,7 +202,7 @@ export function EditableWONumberCell({ padding: "2px 4px", }} > - {value ? : "-"} + )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx index f4becca8..1ef6512e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-input.tsx @@ -1,3 +1,5 @@ +import { sanitizeBoardWoNumberInput } from "@/domain/work-orders/utils/wo-number-input"; + type EditableWONumberInputProps = { draft: string; invalid: boolean; @@ -21,9 +23,8 @@ export function EditableWONumberInput({ onChange(event.target.value.replace(/\D/g, "").slice(0, 11))} + onChange={(event) => onChange(sanitizeBoardWoNumberInput(event.target.value))} onFocus={(event) => event.target.select()} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { @@ -36,7 +37,7 @@ export function EditableWONumberInput({ } }} onBlur={onBlur ?? onCommit} - placeholder="12345678901" + placeholder="e.g. 12345678901 or SH00001" aria-label="Work order number" aria-invalid={invalid} className="font-mono focus:outline-none" diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx index 1df4a262..8f7d75a6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx @@ -39,7 +39,7 @@ export function PmTypePickerPopover({ autoFocus value={search} onChange={(e) => onSearchChange(e.target.value)} - placeholder="Search PM type…" + placeholder="Search service…" className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" />
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx index 48ecbec6..bdccff61 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx @@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = { pocNotes: string; additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; + primaryRequired?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; @@ -19,6 +20,7 @@ export function SiteDialogPocSection({ pocNotes, additionalContacts, showPrimaryErrors = false, + primaryRequired = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -31,6 +33,7 @@ export function SiteDialogPocSection({ pocNotes={pocNotes} additionalContacts={additionalContacts} showPrimaryErrors={showPrimaryErrors} + primaryRequired={primaryRequired} onPocNameChange={onPocNameChange} onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 239c883e..06c5e6bb 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -86,7 +86,7 @@ export function SiteDialog({
- Site + Site void; onRequestComplete?: () => void; readOnly?: boolean; + /** Explicit list for inline create; existing WOs derive valid transitions from `wo.status`. */ statusOptions?: readonly WizardWOStatus[]; }; @@ -128,10 +129,11 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, - statusOptions = BOARD_INLINE_STATUSES, + statusOptions, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); + const options = statusOptions ?? getInlineStatusTransitions(wo.status); if (readOnly) { return ; @@ -169,7 +171,7 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }} > - {statusOptions.map((status) => { + {options.map((status) => { const option = getStatusOptionState(wo, status, closabilityGaps); return ( diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index ae33d713..acea0710 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -9,6 +9,7 @@ import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify- import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify"; import type { WoTableRowHandlers } from "./wo-table-row"; +import { clearDueDateBeforeSchedule } from "@/domain/work-orders/utils/due-date-floor"; function revealOrNotifyScheduledDay(args: { id: string | number; @@ -31,6 +32,35 @@ function revealOrNotifyScheduledDay(args: { revealScheduledRow(id); } +type DragHandlersArgs = Pick< + ReturnTypeOfUseWorkOrdersTableData, + "dragState" | "setDragViz" | "reorderRows" | "endDrag" +>; + +function createDragHandlers({ dragState, setDragViz, reorderRows, endDrag }: DragHandlersArgs) { + return { + onDragStart: (id: string | number) => { + dragState.current = { dragId: id, overId: null }; + setDragViz({ dragId: id, overId: null }); + }, + onDragOver: (id: string | number) => { + const did = dragState.current.dragId; + if (did && did !== id && dragState.current.overId !== id) { + dragState.current.overId = id; + setDragViz((v) => ({ ...v, overId: id })); + } + }, + onDrop: () => { + const { dragId, overId } = dragState.current; + if (dragId && overId) { + reorderRows(dragId, overId); + } + endDrag(); + }, + onDragEnd: endDrag, + }; +} + type UseWoTableRowHandlersOptions = { tableData: ReturnTypeOfUseWorkOrdersTableData; weekMonday: string; @@ -101,8 +131,11 @@ export function useWoTableRowHandlers({ onPatch: (id, patch, options) => { onPatch(id, patch, options); }, - onScheduleDate: (id: string | number, date: string) => { - onPatch(id, applySchedulePatch(date, false)); + onScheduleDate: (id: string | number, date: string, dueDate?: string) => { + onPatch(id, { + ...applySchedulePatch(date, false), + ...clearDueDateBeforeSchedule(dueDate, date), + }); revealOrNotifyScheduledDay({ id, date, @@ -111,8 +144,11 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleRange: (id: string | number, start: string, end: string) => { - onPatch(id, applyScheduleRangePatch(start, end)); + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => { + onPatch(id, { + ...applyScheduleRangePatch(start, end), + ...clearDueDateBeforeSchedule(dueDate, start), + }); revealOrNotifyScheduledDay({ id, date: start, @@ -121,7 +157,7 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleWeek: (id: string | number, week: string) => { + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => { const isClear = week === ""; onPatch( id, @@ -135,6 +171,7 @@ export function useWoTableRowHandlers({ schedMode: "week", dayGroup: "", dayLabel: "", + ...clearDueDateBeforeSchedule(dueDate, week), }, ); weekMoveNotify(week, { @@ -153,25 +190,12 @@ export function useWoTableRowHandlers({ onRequestComplete, onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onReorder: reorderRows, - onDragStart: (id: string | number) => { - dragStateRef.current = { dragId: id, overId: null }; - setDragViz({ dragId: id, overId: null }); - }, - onDragOver: (id: string | number) => { - const did = dragStateRef.current.dragId; - if (did && did !== id && dragStateRef.current.overId !== id) { - dragStateRef.current.overId = id; - setDragViz((v) => ({ ...v, overId: id })); - } - }, - onDrop: () => { - const { dragId, overId } = dragStateRef.current; - if (dragId && overId) { - reorderRows(dragId, overId); - } - endDrag(); - }, - onDragEnd: endDrag, + ...createDragHandlers({ + dragState: dragStateRef, + setDragViz, + reorderRows, + endDrag, + }), }), [ tableData, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts index 96b1caa2..1051ea00 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -11,9 +11,10 @@ export const WO_TABLE_COLS = [ { key: "vendor", label: "VENDOR", w: 184 }, { key: "appt", label: "APPT TIME", w: 128 }, { key: "status", label: "STATUS", w: 148 }, + // Completed Date gates the Completion Document, so it sits between Status and Comp Doc. + { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "doc", label: "COMP DOC", w: 84 }, { key: "uplift", label: "UPLIFT", w: 108 }, - { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "actions", label: "", w: 100 }, ] as const; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx index c490f17e..14a446fd 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx @@ -1,4 +1,5 @@ import { WO_TABLE_COLS } from "./wo-table-cols"; +import { WoTableHeaderLabel } from "./wo-table-header-label"; export function WoTableHead() { return ( @@ -25,7 +26,7 @@ export function WoTableHead() { borderBottom: "1px solid var(--border)", }} > - {col.label} + {Boolean(srLabel) && {srLabel}} ); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-header-label.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-header-label.tsx new file mode 100644 index 00000000..1902b463 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-header-label.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@mui/material"; +import { CircleQuestionMark } from "lucide-react"; + +import { getWoTableHeaderTooltip } from "@/domain/work-orders/constants/wo-table-header-tooltips"; + +type WoTableHeaderLabelProps = { + colKey: string; + label: string; +}; + +export function WoTableHeaderLabel({ colKey, label }: WoTableHeaderLabelProps) { + const tooltip = getWoTableHeaderTooltip(colKey); + + if (!tooltip) { + return <>{label}; + } + + return ( + + {label} + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx index 47ca7b46..50915a28 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx @@ -89,6 +89,7 @@ export function WoTableInlineIdentityCells({ allOrders={allRows as never[]} onSave={(v) => onPatch({ woNumber: v })} onDuplicateFound={onDuplicateFound} + requiredPlaceholder="Insert WO #" /> +
+ ); +} + function InlineTypeSeverityCell({ draft, onPatch, @@ -51,20 +67,22 @@ function InlineTypeSeverityCell({ onPatch: (patch: Partial) => void; }) { return ( - + ); } function InlineMutedDashCell() { return ( - ); } @@ -81,18 +99,18 @@ function InlineScheduleOnCell({ const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); return ( - + ); } @@ -184,26 +204,26 @@ export function WoTableInlineRowCells({ - + - - - - - + diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index f6258ee0..ffce7992 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -115,15 +115,17 @@ export function WoTableInlineRow({ pointerEvents: saving ? "none" : undefined, }} > - setConfirmComplete(true)} /> - @@ -163,6 +164,7 @@ export function WoTableRowCells({ q={search} highlight={forceDueDateHighlight || row.isPastDue} pastDue={row.isPastDue} + minDate={resolveDueDateFloor(row)} onSave={(date) => onPatchRow({ dueDate: date })} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 0326f870..ad995968 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -105,6 +105,17 @@ export function WoTableRowServiceCells({ onRequestComplete={() => handlers.onRequestComplete?.(row)} /> + - ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index 1abf21e5..8a7814ec 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -36,9 +36,10 @@ export type WoTableRowHandlers = { patch: Partial, options?: WorkOrderTablePatchOptions, ) => void; - onScheduleDate: (id: string | number, date: string) => void; - onScheduleWeek: (id: string | number, week: string) => void; - onScheduleRange: (id: string | number, start: string, end: string) => void; + /** `dueDate` is the row's current Due Date, cleared when the new schedule passes it (SH-356). */ + onScheduleDate: (id: string | number, date: string, dueDate?: string) => void; + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => void; + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onOpenSlideOver: (row: WorkOrderTableRow) => void; onRequestComplete?: (row: WorkOrderTableRow) => void; @@ -77,17 +78,17 @@ function guardScheduleHandlers( if (!canApplyWorkOrderTablePatch(row.status, patch)) return; handlers.onPatch(id, patch, options); }, - onScheduleDate: (id, date) => { + onScheduleDate: (id, date, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; - handlers.onScheduleDate(id, date); + handlers.onScheduleDate(id, date, dueDate); }, - onScheduleWeek: (id, week) => { + onScheduleWeek: (id, week, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return; - handlers.onScheduleWeek(id, week); + handlers.onScheduleWeek(id, week, dueDate); }, - onScheduleRange: (id, start, end) => { + onScheduleRange: (id, start, end, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return; - handlers.onScheduleRange(id, start, end); + handlers.onScheduleRange(id, start, end, dueDate); }, }; } diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx index 8208d7de..3f52ea0f 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-header.tsx @@ -5,31 +5,49 @@ import { ToolbarButton } from "@/components/ui/toolbar-button"; type WorkOrdersListHeaderProps = { search: string; + /** Compact label of the active board week (e.g. "5/4–5/8"). */ + weekLabel: string; onSearchChange: (value: string) => void; /** Omit until Advanced Search slice mounts the sheet. */ onOpenAdvancedFilters?: () => void; /** Omit until wizard shell slice mounts New WO. */ onNewWorkOrder?: () => void; activeFilterCount: number; + /** True while Advanced Filters are applied; results are global and the keyword box is inert. */ + advancedFiltersActive?: boolean; isFetching?: boolean; }; export function WorkOrdersListHeader({ search, + weekLabel, onSearchChange, onOpenAdvancedFilters, onNewWorkOrder, activeFilterCount, + advancedFiltersActive = false, isFetching = false, }: WorkOrdersListHeaderProps) { return ( <> - +
+ + {/* Keyword search is scoped to the active week; make that scope visible. */} + {!advancedFiltersActive && search.trim() !== "" && Boolean(weekLabel) && ( + + Searching within {weekLabel} + + )} +
{Boolean(onOpenAdvancedFilters) && ( void; helperText?: string; showPrimaryErrors?: boolean; + /** Labels the primary name/phone inputs with a persistent required asterisk. */ + primaryRequired?: boolean; }; function extraNameMissing(contact: WorkOrderAdditionalContact): boolean { @@ -135,7 +138,9 @@ export function PocContactFields({ onAdditionalContactsChange, helperText, showPrimaryErrors = false, + primaryRequired = false, }: PocContactFieldsProps) { + const fieldId = useId(); const nameMissing = showPrimaryErrors && !pocName.trim(); const phoneMissing = showPrimaryErrors && !pocPhone.trim(); @@ -153,7 +158,13 @@ export function PocContactFields({ )}
+ {primaryRequired && ( + + POC name + + )} onPocNameChange(event.target.value)} placeholder="POC name" @@ -168,7 +179,13 @@ export function PocContactFields({
+ {primaryRequired && ( + + POC phone + + )} onPocPhoneChange(event.target.value)} placeholder="POC phone" diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx index e9aa9dab..5b494167 100644 --- a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx @@ -12,6 +12,8 @@ type MiniCalendarProps = { range?: boolean; rangeStart?: string; rangeEnd?: string; + /** Days before this ISO date are disabled (SH-356 Due Date floor). */ + minDate?: string; }; export function MiniCalendar({ @@ -20,9 +22,11 @@ export function MiniCalendar({ range, rangeStart = "", rangeEnd = "", + minDate = "", }: MiniCalendarProps) { const initRaw = range ? rangeStart || value : value; - const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : todayIso(); + const fallback = minDate > todayIso() ? minDate : todayIso(); + const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : fallback; const [iy, im] = init.split("-").map(Number); const [viewY, setViewY] = useState(iy); const [viewM, setViewM] = useState(im); @@ -94,6 +98,7 @@ export function MiniCalendar({ } const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`; const isToday = iso === today; + const beforeMin = Boolean(minDate) && iso < minDate; const { bg, fg, radius, filled } = getMiniCalendarCellStyle({ iso, value, @@ -107,8 +112,9 @@ export function MiniCalendar({ - + ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx index 61bf4927..c7916686 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx @@ -26,6 +26,7 @@ export function WizardStepLocationPocFields({ onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)} helperText="Auto-filled from records — edit if outdated" showPrimaryErrors={showErrors && pocIncomplete} + primaryRequired /> ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx index c9b2c41a..6f61b782 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx @@ -34,8 +34,10 @@ export function WizardStepLocationServiceSelect({ : {}; const selectSvc = (p: string) => { + const selected = services.find((service) => service.value === p); onPatch({ pm: p, + serviceId: selected?.id ?? null, extraServices: [], ...clearVendorIfPrimaryChanged(p), }); @@ -50,7 +52,7 @@ export function WizardStepLocationServiceSelect({ icon={ } - searchPlaceholder="Search PM type…" + searchPlaceholder="Search services…" options={services.map((s) => { const Icon = getPmTypeIcon(s.value); return { diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index f4df71a3..7a655742 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -29,6 +29,10 @@ import { import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker"; import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react"; import type { ElementType } from "react"; +import { + clearDueDateBeforeSchedule, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; const WO_TYPE_ICONS: Record = { PM: Wrench, @@ -165,6 +169,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChange={(iso) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, iso), scheduledOn: iso, scheduleWeekOnly: false, scheduleEnd: "", @@ -177,6 +182,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeWeek={(w) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, w), scheduledOn: w, scheduleWeekOnly: Boolean(w), scheduleEnd: "", @@ -189,6 +195,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeRange={(s, e) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, s), scheduledOn: s, scheduleEnd: e, schedMode: "range", @@ -211,6 +218,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onUpdate("dueDate", iso)} /> @@ -262,9 +270,23 @@ export function WizardStepTypeSchedule({ { + const typeChanged = t !== draft.type; onPatch({ type: t, severity: nextSeverityForType(draft.type, t, draft.severity), + // A registry service is scoped to its work-order type, so a service picked + // under the previous type fails backend ResolveServiceAsync (SH-187/#131). + // Clear the service (and its vendor, filtered by the service) on a type change. + ...(typeChanged + ? { + pm: "", + serviceId: null, + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + } + : {}), }); }} /> diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts index 7522bb47..cbc1da83 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts @@ -33,7 +33,8 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar () => buildResolvedBoardSearchParams({ advApplied: filters.advApplied, - search: filters.search, + // Advanced Filters are global by design; the week-scoped keyword box has no effect. + search: "", page: filters.page, pageSize: filters.pageSize, sortBy: filters.sortBy, @@ -43,7 +44,6 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar }), [ filters.advApplied, - filters.search, filters.page, filters.pageSize, filters.sortBy, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 266d84e3..182a98e5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -8,6 +8,7 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; import { patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, @@ -112,7 +113,17 @@ export function useWorkOrdersListPage() { generatedAt: data.generatedAt, }; setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData })); - await patchFieldAsync(row.id, { docStatus: status }); + const saved = await patchFieldAsync(row.id, { docStatus: status }); + if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) { + return; + } + try { + // Generation is the one automatic completion — no confirmation dialog. + await patchFieldAsync(row.id, { status: "Completed" }, saved.row); + } catch { + // The document is saved; the table mutation already surfaced the status error and + // refetched, so the WO stays in its prior status for a manual retry. + } }; return { diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 571251cb..e12e040d 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -43,10 +43,12 @@ export default function WorkOrdersListPage() { actions={ filters.setAdvOpen(true)} onNewWorkOrder={() => page.setWizardOpen(true)} activeFilterCount={filters.activeFilterCount} + advancedFiltersActive={filters.advApplied != null} isFetching={tableData.isFetching && !tableData.isLoading} /> } diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 4e672fa5..6713c559 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -1,4 +1,9 @@ -import type { Location, LocationListItem, LocationSite } from "@/domain/locations/types/location"; +import type { + Location, + LocationContact, + LocationListItem, + LocationSite, +} from "@/domain/locations/types/location"; function readString(record: Record, ...keys: string[]): string { for (const key of keys) { @@ -90,9 +95,31 @@ export function mapLocationSites(rawItems: unknown[]): LocationSite[] { return rawItems.map(mapLocationSite); } +/** Reads the additive contacts array; returns [] when absent or malformed. */ +export function mapLocationContacts(raw: unknown): LocationContact[] { + if (!Array.isArray(raw)) { + return []; + } + return raw.map((entry) => { + const item = (typeof entry === "object" && entry !== null ? entry : {}) as Record< + string, + unknown + >; + const id = item.id ?? item.Id; + return { + ...(typeof id === "number" && Number.isInteger(id) && id > 0 ? { id } : {}), + name: readString(item, "name", "Name", "contact", "Contact"), + phone: readString(item, "phone", "Phone"), + }; + }); +} + export function mapLocationListItem(raw: unknown): LocationListItem { const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const contacts = mapLocationContacts(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + return { id: readId(item), name: readString(item, "name", "Name"), @@ -100,11 +127,14 @@ export function mapLocationListItem(raw: unknown): LocationListItem { city: readString(item, "city", "City"), state: readString(item, "state", "State"), zipCode: readString(item, "zipCode", "ZipCode", "Zip"), - phone: readString(item, "phone", "Phone"), - contact: readString(item, "contact", "Contact", "contactPerson", "ContactPerson"), + phone: mainContact ? mainContact.phone : readString(item, "phone", "Phone"), + contact: mainContact + ? mainContact.name + : readString(item, "contact", "Contact", "contactPerson", "ContactPerson"), contactEmail: readString(item, "contactEmail", "ContactEmail"), status: readString(item, "status", "Status"), accountId: readOptionalId(item), + contacts: contacts.length > 0 ? contacts : undefined, }; } @@ -122,6 +152,12 @@ export function mapLocation(raw: unknown): Location { .join(", ") || undefined; + const contacts = mapLocationContacts(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + const legacyPhone = readString(item, "phone", "Phone") || undefined; + const legacyContact = + readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined; + return { id: readId(item) || undefined, name: readString(item, "name", "Name") || undefined, @@ -129,31 +165,58 @@ export function mapLocation(raw: unknown): Location { city: readString(item, "city", "City") || undefined, state: readString(item, "state", "State") || undefined, zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined, - phone: readString(item, "phone", "Phone") || undefined, - contact: readString(item, "contact", "Contact") || undefined, + phone: mainContact ? mainContact.phone : legacyPhone, + contact: mainContact ? mainContact.name : legacyContact, contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined, status: readString(item, "status", "Status") || undefined, accountId: readOptionalId(item) || undefined, + contacts: contacts.length > 0 ? contacts : undefined, }; } +/** Request payload contacts: trimmed, in displayed order. */ +export function mapContactsToBackend(raw: unknown): LocationContact[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .map((entry) => { + const item = (typeof entry === "object" && entry !== null ? entry : {}) as Record< + string, + unknown + >; + return { + ...(typeof item.id === "number" && Number.isInteger(item.id) && item.id > 0 + ? { id: item.id } + : {}), + name: readString(item, "name", "Name", "contact", "Contact").trim(), + phone: readString(item, "phone", "Phone").trim(), + }; + }) + .filter((contact) => contact.name !== "" || contact.phone !== ""); +} + export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, unknown >; + const contacts = mapContactsToBackend(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + const payload: Record = { name: item.name ?? "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: item.phone ?? "", - contact: item.contact ?? "", + phone: mainContact ? mainContact.phone : (item.phone ?? ""), + contact: mainContact ? mainContact.name : (item.contact ?? ""), contactEmail: item.contactEmail ?? "", status: item.status ?? "Active", accountId: item.accountId ?? "", + contacts, }; const id = item.id ?? item.Id; diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 00fbbdbb..7c9e878c 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -2,6 +2,16 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +export const locationContactSchema = z.object({ + id: z.number().int().positive().optional(), + name: z + .string() + .trim() + .min(1, "Contact person is required") + .max(100, "Contact person cannot exceed 100 characters"), + phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), +}); + export const locationSchema = z.object({ name: z.string().min(1, "Name is required"), accountId: z.string().optional(), @@ -11,13 +21,13 @@ export const locationSchema = z.object({ .union([z.enum(US_STATE_CODES), z.literal("")]) .refine((state): boolean => state !== "", { message: "State is required" }), zipCode: z.string().min(1, "Zip code is required"), - phone: z.string().min(1, "Phone is required"), - contact: z.string().min(1, "Contact person is required"), + contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), }); export type LocationFormValues = z.infer; +export type LocationContactFormValues = z.infer; export const locationDefaultValues: LocationFormValues = { name: "", @@ -26,8 +36,7 @@ export const locationDefaultValues: LocationFormValues = { city: "", state: "", zipCode: "", - phone: "", - contact: "", + contacts: [{ name: "", phone: "" }], contactEmail: "", status: "Active", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 07ecbe2e..7fa99625 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -1,5 +1,12 @@ import type { StateCode } from "@/data/areas"; +/** Site contact row; the first entry in `contacts` is the Main contact. */ +export interface LocationContact { + id?: number; + name: string; + phone: string; +} + export interface LocationListItem { id: string | number; name: string; @@ -12,6 +19,8 @@ export interface LocationListItem { contactEmail: string; status: string; accountId: string | number; + /** Additive contacts payload; `contact`/`phone` mirror the first entry. */ + contacts?: LocationContact[]; } export interface Location { @@ -26,6 +35,8 @@ export interface Location { contactEmail?: string; status?: string; accountId?: string | number; + /** Additive contacts payload; the first entry is the Main contact. */ + contacts?: LocationContact[]; } /** Site option from GET locations/sites (wizard / board create). */ diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts new file mode 100644 index 00000000..58ba217c --- /dev/null +++ b/src/domain/services/api/services-api.ts @@ -0,0 +1,30 @@ +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapServices } from "@/domain/services/mappers/service-mapper"; +import type { Service } from "@/domain/services/types/service"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +const TYPE_TO_API: Record, number> = { + PM: WORK_ORDER_TYPE_API.PM, + Reactive: WORK_ORDER_TYPE_API.Reactive, + Emergency: WORK_ORDER_TYPE_API.Emergency, +}; + +export const servicesApi = { + getActive: async (): Promise => { + const data = await apiGet(API_PATHS.services.list, { + searchParams: { isActive: true }, + }); + return mapServices(handleApiResponse(data)); + }, + + getActiveForWorkOrderType: async (workOrderType: WOType): Promise => { + if (workOrderType === "Overdue") return []; + const data = await apiGet(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, + }); + return mapServices(handleApiResponse(data)); + }, +}; diff --git a/src/domain/services/mappers/service-mapper.ts b/src/domain/services/mappers/service-mapper.ts new file mode 100644 index 00000000..4b6d6f6e --- /dev/null +++ b/src/domain/services/mappers/service-mapper.ts @@ -0,0 +1,71 @@ +import type { Service } from "@/domain/services/types/service"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readId(item: Record): string | number { + const value = item.id ?? item.Id; + return typeof value === "string" || typeof value === "number" ? value : ""; +} + +function readString(item: Record, ...keys: string[]): string { + for (const key of keys) { + const value = item[key]; + if (typeof value === "string") return value.trim(); + } + return ""; +} + +function readBool(item: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = item[key]; + if (typeof value === "boolean") return value; + } + return false; +} + +function mapWorkOrderType(value: unknown): WOType | null { + if (typeof value === "number") { + return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "pm" || normalized === "preventivemaintenance" + ? "PM" + : normalized === "emergency" + ? "Emergency" + : normalized === "reactive" + ? "Reactive" + : null; + } + return null; +} + +export function mapService(raw: unknown): Service { + const item = asRecord(raw); + const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes; + return { + id: readId(item), + name: readString(item, "name", "Name"), + trade: readString(item, "trade", "Trade"), + iconKey: readString(item, "iconKey", "IconKey"), + requiresCompletionDocument: readBool( + item, + "requiresCompletionDocument", + "RequiresCompletionDocument", + ), + isActive: readBool(item, "isActive", "IsActive"), + supportedWorkOrderTypes: Array.isArray(types) + ? types.flatMap((value) => { + const mapped = mapWorkOrderType(value); + return mapped ? [mapped] : []; + }) + : [], + }; +} + +export function mapServices(raw: unknown): Service[] { + return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : []; +} diff --git a/src/domain/services/types/service.ts b/src/domain/services/types/service.ts new file mode 100644 index 00000000..fa7a87d9 --- /dev/null +++ b/src/domain/services/types/service.ts @@ -0,0 +1,11 @@ +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +export interface Service { + id: string | number; + name: string; + trade: string; + iconKey: string; + requiresCompletionDocument: boolean; + isActive: boolean; + supportedWorkOrderTypes: WOType[]; +} diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts new file mode 100644 index 00000000..6dd8d4ac --- /dev/null +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -0,0 +1,25 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { servicesApi } from "@/domain/services/api/services-api"; +import type { Service } from "@/domain/services/types/service"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useServicesForWorkOrderType( + workOrderType: WOType | "", +): UseQueryResult { + const effectiveType = workOrderType || "PM"; + return useQuery({ + queryKey: queryKeys.services.byWorkOrderType(effectiveType), + queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), + enabled: effectiveType !== "Overdue", + staleTime: 60 * 1000, + }); +} + +export function useActiveServices(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.services.active(), + queryFn: () => servicesApi.getActive(), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 5afbc13e..3d925b76 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -56,6 +56,7 @@ function buildListParams(params: VendorsListParams): Record => { + const data = await apiGet(API_PATHS.vendor.getDirectoryList, { + searchParams: buildListParams(params), + }); + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + return { + items: mapVendorListItems(paginated.items), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + getFacets: async (params?: VendorFacetsParams): Promise => { const data = await apiGet(API_PATHS.vendor.facets, { searchParams: buildFacetsParams(params), diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 321e4542..200fe8c9 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -1,6 +1,8 @@ import type { Vendor, VendorDeactivationImpact, + VendorDirectoryTechnician, + VendorFacetArea, VendorFacetCompany, VendorFacetJobBucket, VendorFacetLocation, @@ -108,7 +110,7 @@ function readBool(record: Record, ...keys: string[]): boolean { export function mapVendorListItem(raw: unknown): VendorListItem { const item = asRecord(raw); - return { + const mapped = { id: readId(item), companyId: readCompanyId(item), companyName: readString(item, "companyName", "CompanyName"), @@ -125,6 +127,42 @@ export function mapVendorListItem(raw: unknown): VendorListItem { tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), isActive: readBool(item, "isActive", "IsActive"), preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), + areaId: readString(item, "areaId", "AreaId"), + areaName: readString(item, "areaName", "AreaName"), + }; + const rawTechnicians = item.technicians ?? item.Technicians; + const technicians = Array.isArray(rawTechnicians) + ? rawTechnicians.map(mapVendorDirectoryTechnician) + : mapped.id == null + ? [] + : [ + { + id: mapped.id, + contactName: mapped.contactName, + email: mapped.email, + phone: mapped.phone, + preferredContact: mapped.preferredContact, + tradeSpecialties: mapped.tradeSpecialties, + isActive: mapped.isActive, + totalJobs: mapped.totalJobs, + }, + ]; + + return { ...mapped, technicians }; +} + +function mapVendorDirectoryTechnician(raw: unknown): VendorDirectoryTechnician { + const item = asRecord(raw); + const id = readId(item); + return { + id: id ?? "", + contactName: readString(item, "contactName", "ContactName"), + email: readString(item, "email", "Email"), + phone: readString(item, "phone", "Phone"), + preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), + tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), + isActive: readBool(item, "isActive", "IsActive"), + totalJobs: readNumber(item, "totalJobs", "TotalJobs"), }; } @@ -255,6 +293,14 @@ function mapFacetLocation(raw: unknown): VendorFacetLocation { }; } +function mapFacetArea(raw: unknown): VendorFacetArea { + const item = asRecord(raw); + return { + id: readString(item, "id", "Id"), + name: readString(item, "name", "Name"), + }; +} + function mapFacetJobBucket(raw: unknown): VendorFacetJobBucket { const item = asRecord(raw); return { @@ -269,6 +315,7 @@ export function mapVendorFacets(raw: unknown): VendorFacets { const tradesRaw = item.trades ?? item.Trades; const locationsRaw = item.locations ?? item.Locations; const jobBucketsRaw = item.jobBuckets ?? item.JobBuckets; + const areasRaw = item.areas ?? item.Areas; return { companies: Array.isArray(companiesRaw) ? companiesRaw.map(mapFacetCompany) : [], @@ -280,6 +327,9 @@ export function mapVendorFacets(raw: unknown): VendorFacets { }) .filter(Boolean) : [], + areas: Array.isArray(areasRaw) + ? areasRaw.map(mapFacetArea).filter((area) => area.id !== "" && area.name !== "") + : [], locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [], jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [], }; diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 27672d30..9fd986d0 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -92,10 +92,18 @@ export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster { zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), + areaId: readString(item, "areaId", "AreaId"), + areaName: readString(item, "areaName", "AreaName"), technicians: mapRosterTechnicians(item.technicians ?? item.Technicians), }; } +/** Form Area ids are strings; the API takes a positive integer id or null (Unassigned). */ +export function toBackendAreaId(value: unknown): number | null { + const parsed = Number(typeof value === "string" ? value.trim() : value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + export function mapRosterTechnicianToBackend(technician: unknown): Record { const item = asRecord(technician); const payload: Record = { @@ -130,6 +138,11 @@ export function mapVendorRosterToBackend(values: unknown): Record = { ...patch.companyFields }; if (companyFields.companyPhone !== undefined) companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone); + // An edited Area is sent as an id or an explicit null (Unassigned). + if (companyFields.areaId !== undefined) + companyFields.areaId = toBackendAreaId(companyFields.areaId); payload.companyFields = companyFields; } return payload; diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts index fb05edee..1f590cbe 100644 --- a/src/domain/vendors/schemas/vendor-roster-schema.ts +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -54,6 +54,8 @@ const baseCompanyFields = { notes: z .string() .max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`), + // Optional stable Area id; "" means Unassigned. Admin-only to change. + areaId: z.string(), technicians: z.array(rosterTechnicianSchema), }; @@ -107,5 +109,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = { state: "", zip: "", notes: "", + areaId: "", technicians: [], }; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index b6ee694d..878e2336 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -2,6 +2,17 @@ export type VendorPreferredContact = "Phone" | "Email" | "Text"; export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"]; +export interface VendorDirectoryTechnician { + id: string | number; + contactName: string; + email: string; + phone: string; + preferredContact: VendorPreferredContact; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +} + export interface VendorListItem { id: string | number | null; companyId: string | number | null; @@ -19,6 +30,12 @@ export interface VendorListItem { tradeSpecialties: string; isActive: boolean; preferredContact: VendorPreferredContact; + /** Stable Area id as a string; "" when the company is Unassigned. */ + areaId: string; + /** Area display name; "" when the company is Unassigned. */ + areaName: string; + /** Present on company-grouped directory responses; optional for legacy clients/fixtures. */ + technicians?: VendorDirectoryTechnician[]; } export interface Vendor { @@ -50,6 +67,8 @@ export interface VendorsListParams { trades?: string[]; locations?: string[]; jobBuckets?: string[]; + /** Stable Area ids or the "__unassigned__" sentinel. */ + areas?: string[]; } export interface VendorsListResult { @@ -82,9 +101,19 @@ export interface VendorFacetJobBucket { label: string; } +/** Active Area catalogue option. `id` is the stable areas[n] filter value. */ +export interface VendorFacetArea { + id: string; + name: string; +} + +/** Wire value that selects companies without an Area. */ +export const VENDOR_AREA_UNASSIGNED = "__unassigned__"; + export interface VendorFacets { companies: VendorFacetCompany[]; trades: string[]; + areas: VendorFacetArea[]; locations: VendorFacetLocation[]; jobBuckets: VendorFacetJobBucket[]; } @@ -134,6 +163,9 @@ export interface VendorCompanyRoster { zip: string; googleMapsUrl: string; notes: string; + /** Stable Area id as a string; "" when the company is Unassigned. */ + areaId: string; + areaName: string; technicians: VendorRosterTechnician[]; } 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 fdfb055a..89e35054 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 @@ -38,6 +38,7 @@ const COMPANY_FIELDS = [ "state", "zip", "notes", + "areaId", ] as const; const TECHNICIAN_FIELDS = [ diff --git a/src/domain/vendors/use-cases/use-vendor-directory-list.ts b/src/domain/vendors/use-cases/use-vendor-directory-list.ts new file mode 100644 index 00000000..b610af63 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-directory-list.ts @@ -0,0 +1,14 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import type { VendorsListParams, VendorsListResult } from "@/domain/vendors/types/vendor"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useVendorDirectoryList( + params: VendorsListParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.vendors.directoryList(params as Record), + queryFn: () => vendorsApi.getDirectoryList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/domain/work-orders/constants/wo-table-header-tooltips.ts b/src/domain/work-orders/constants/wo-table-header-tooltips.ts new file mode 100644 index 00000000..24ff23be --- /dev/null +++ b/src/domain/work-orders/constants/wo-table-header-tooltips.ts @@ -0,0 +1,17 @@ +/** + * Help copy for date-column headers on the work orders board table. + * Keyed by the `WO_TABLE_COLS` column key; only columns with copy here show a + * header help icon. `appt` (Appt Time) is an intentional open point — do not + * add it without a business-rule decision. + */ +export const WO_TABLE_HEADER_TOOLTIPS = { + sched: "The date the work is planned to happen. Setting it moves the work order to Scheduled.", + due: "The deadline for the work — when it must be completed by.", + completed: "The date the vendor actually finished the work. Required to complete the work order.", +} as const; + +export function getWoTableHeaderTooltip(colKey: string): string | undefined { + return colKey in WO_TABLE_HEADER_TOOLTIPS + ? WO_TABLE_HEADER_TOOLTIPS[colKey as keyof typeof WO_TABLE_HEADER_TOOLTIPS] + : undefined; +} diff --git a/src/domain/work-orders/errors/board-create-errors.ts b/src/domain/work-orders/errors/board-create-errors.ts new file mode 100644 index 00000000..446eaf5f --- /dev/null +++ b/src/domain/work-orders/errors/board-create-errors.ts @@ -0,0 +1,45 @@ +import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; +import { mapHttpStatusToMessage } from "@/api/api-error"; +import { DISPATCH_REQUIRED_MESSAGE } from "@/domain/work-orders/errors/board-patch-errors"; + +export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again."; + +const DUPLICATE_WO_NUMBER_MESSAGE = + "This work order number is already assigned to another work order."; + +/** Statuses whose mapped copy is fixed product text, never the response body. */ +const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); + +function readErrorCode(data: unknown): string | undefined { + if (typeof data !== "object" || data === null) { + return undefined; + } + const record = data as Record; + const code = record.code ?? record.Code; + return typeof code === "string" ? code : undefined; +} + +/** + * why: board create returns backend validation text ("siteCode is required.", + * "vendorId is required when setting appointment fields.") that must never reach the + * screen. Unknown failures fall back to generic copy, not to `error.message`. + */ +export function boardCreateErrorMessage(error: unknown): string { + if (isHTTPError(error)) { + const code = readErrorCode(error.data); + if (code === "DispatchRequired") { + return DISPATCH_REQUIRED_MESSAGE; + } + if (code === "DuplicateWoNumber") { + return DUPLICATE_WO_NUMBER_MESSAGE; + } + const status = error.response.status; + return FIXED_COPY_STATUSES.has(status) + ? mapHttpStatusToMessage(status) + : BOARD_CREATE_FAILED_MESSAGE; + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return BOARD_CREATE_FAILED_MESSAGE; +} diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts index d4e9fdbd..00779894 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -22,10 +22,12 @@ export class BoardPatchValidationError extends Error { } } +export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving."; + /** why: backend field names must never be exposed through user-facing board toasts. */ export function boardPatchValidationMessage(error: BoardPatchValidationError): string { if (error.code === "DispatchRequired") { - return "Select a vendor company before saving."; + return DISPATCH_REQUIRED_MESSAGE; } return error.message; } diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts index 32e87840..e45381e7 100644 --- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts @@ -19,6 +19,10 @@ export function assignWizardServicePocFields( payload.primaryService = primaryService; } + if (draft.serviceId != null) { + payload.serviceId = draft.serviceId; + } + const serviceNotes = trimOrUndefined(draft.pmNote); if (serviceNotes) { diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index d1d4afc3..2f019989 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -400,6 +400,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { locationId: typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "", pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"), + serviceId: readNumber(item, "serviceId", "ServiceId"), serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"), extraServices: readStringArray(item, "extraServices", "ExtraServices"), vendorId: vendorIdNum != null ? String(vendorIdNum) : "", diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 4c5606b4..b7f2693a 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -105,6 +105,8 @@ export interface WorkOrderBoardCreatePayload { trade?: string; /** Primary service label; persisted as board `pm`. */ primaryService?: string; + /** Registry service identity; the backend validates active/type-compatible selections. */ + serviceId?: string | number; /** Services beyond primary; order preserved. */ extraServices?: string[]; /** Issue detail from Service & Notes (`pmNote` in draft). */ @@ -171,6 +173,7 @@ export interface WorkOrderBoardRowApi { originalDate?: string | null; originalWeek?: string | null; workOrderType?: number | null; + serviceId?: number | null; isPastDue?: boolean; siteCode?: string | null; locationName?: string | null; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index d362f931..179f6c95 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -60,6 +60,7 @@ export interface WorkOrderTableRow { location: string; locationId: string | number; pm: string; + serviceId?: string | number | null; /** Issue detail from Service & Notes. */ serviceNotes: string; /** Additional services beyond primary `pm`. */ diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index dc657c92..32cf5e7c 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -42,6 +42,8 @@ export interface WorkOrderWizardDraft { scheduledOn: string; dueDate: string; pm: string; + /** Registry service selected for this work order; null keeps the optional field empty. */ + serviceId: string | number | null; /** Extra services beyond primary `pm` (Service & Notes). */ extraServices: string[]; /** Free-text note attached to the service (Service & Notes). */ @@ -91,6 +93,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { scheduledOn: "", dueDate: "", pm: "", + serviceId: null, extraServices: [], pmNote: "", avetaRequired: false, diff --git a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts index 205f010e..4aa4c8b8 100644 --- a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts +++ b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts @@ -4,6 +4,8 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import { boardCreateErrorMessage } from "@/domain/work-orders/errors/board-create-errors"; + import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; @@ -27,7 +29,7 @@ export function useCreateWorkOrderFromWizard( }, onError: (error: Error) => { - toast.error(error.message || "Failed to create work order"); + toast.error(boardCreateErrorMessage(error)); }, }); } diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index 6922c367..a02950d3 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -134,11 +134,17 @@ export function useWorkOrderTableMutations( mutationFn: ({ id, persistPatch, + baseRow, }: { id: string | number; patch: WorkOrderTablePatch; persistPatch: WorkOrderTablePatch; - }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)), + /** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */ + baseRow?: WorkOrderTableRow; + }) => + enqueuePatch(id, () => + runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow), + ), onMutate: async ({ id, persistPatch }) => { onPatch(id, persistPatch); }, @@ -182,9 +188,13 @@ export function useWorkOrderTableMutations( ); }; - const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => { - const persistPatch = resolvePatch(id, patch); - return patchMutation.mutateAsync({ id, patch, persistPatch }); + const patchFieldAsync = ( + id: string | number, + patch: WorkOrderTablePatch, + baseRow?: WorkOrderTableRow, + ) => { + const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); + return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }); }; return { diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts index 4a9a4c72..e522356f 100644 --- a/src/domain/work-orders/utils/board-audit-action-rules.ts +++ b/src/domain/work-orders/utils/board-audit-action-rules.ts @@ -39,24 +39,53 @@ function formatSeverityAuditLabel(raw: string): string { return raw; } -function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { +const CLEARED_AUDIT_VALUE: FormattedAuditValue = { text: "—", isQuotedList: false }; + +/** `from X to Y` whenever the previous value is known; a cleared value reads as "—". */ +function formatFieldChangeSentence( + label: string, + oldValue: FormattedAuditValue, + newValue: FormattedAuditValue, +): string | null { + if (!oldValue.text && !newValue.text) return null; + const next = wrapAuditValue(newValue.text ? newValue : CLEARED_AUDIT_VALUE); + if (oldValue.text) { + return `Changed ${label} from ${wrapAuditValue(oldValue)} to ${next}`; + } + return `Changed ${label} to ${next}`; +} + +function tryTypeRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { const matches = fieldKey === "type" || fieldKey === "workordertype" || fieldKey === "wotype" || actionKey === "typechanged" || actionKey.includes("workordertype"); - if (!matches || !newValue.text) return null; + if (!matches) return null; - return `Changed Type to ${wrapAuditValue(newValue)}`; + return formatFieldChangeSentence("Type", oldValue, newValue); } -function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { - const matches = fieldKey.includes("severity") || actionKey.includes("severity"); - if (!matches || !newValue.text) return null; +function severityValue(value: FormattedAuditValue): FormattedAuditValue { + return value.text ? { text: formatSeverityAuditLabel(value.text), isQuotedList: false } : value; +} - const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false }; - return `Changed Severity to ${wrapAuditValue(labeled)}`; +function trySeverityRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { + const matches = fieldKey.includes("severity") || actionKey.includes("severity"); + if (!matches) return null; + + return formatFieldChangeSentence("Severity", severityValue(oldValue), severityValue(newValue)); } function tryStatusRule({ diff --git a/src/domain/work-orders/utils/completion-doc-auto-complete.ts b/src/domain/work-orders/utils/completion-doc-auto-complete.ts new file mode 100644 index 00000000..e27f0318 --- /dev/null +++ b/src/domain/work-orders/utils/completion-doc-auto-complete.ts @@ -0,0 +1,25 @@ +import type { CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; + +/** + * Completion is manual with confirmation, except that generating the completion document + * completes the WO — but only when the closability gate passes on the persisted row. + * Not-required (NN) and drafts never complete; Past Due rows must be + * rescheduled before any status change, so they are left for the dispatcher. + */ +export function shouldCompleteAfterCompletionDoc( + status: CompDocSaveStatus, + row: WorkOrderTableRow, +): boolean { + if (status !== "Yes" || row.docStatus !== "Yes") { + return false; + } + if (row.status === "Completed" || row.status === "Canceled" || row.isPastDue) { + return false; + } + return getClosabilityGaps(tableRowToClosabilityInput(row)).length === 0; +} diff --git a/src/domain/work-orders/utils/due-date-floor.ts b/src/domain/work-orders/utils/due-date-floor.ts new file mode 100644 index 00000000..95f8eb54 --- /dev/null +++ b/src/domain/work-orders/utils/due-date-floor.ts @@ -0,0 +1,27 @@ +/** + * SH-356: a Due Date can never be earlier than Schedule On. + * + * The floor is the concrete Schedule On day, the start of a date range, or — for a + * week-only schedule (SH-110) — the first day of the target week. + */ +export function resolveDueDateFloor(schedule: { + scheduledOn?: string | null; + targetWeek?: string | null; +}): string { + return schedule.scheduledOn?.trim() || schedule.targetWeek?.trim() || ""; +} + +export function isDueDateBeforeFloor(dueDate: string | null | undefined, floor: string): boolean { + return Boolean(dueDate && floor && dueDate < floor); +} + +/** + * Patch fragment for a Schedule On change: clears an existing Due Date that the new + * schedule would leave in the past. Never bumps it to a guessed value. + */ +export function clearDueDateBeforeSchedule( + dueDate: string | null | undefined, + nextFloor: string, +): { dueDate?: string } { + return isDueDateBeforeFloor(dueDate, nextFloor) ? { dueDate: "" } : {}; +} diff --git a/src/domain/work-orders/utils/format-audit-timestamp.ts b/src/domain/work-orders/utils/format-audit-timestamp.ts new file mode 100644 index 00000000..4d987df4 --- /dev/null +++ b/src/domain/work-orders/utils/format-audit-timestamp.ts @@ -0,0 +1,24 @@ +const AUDIT_DATE_PATTERN = /\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/; + +/** + * Every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`. + * Pre-formatted API strings are kept only when they already carry a calendar date. + */ +export function formatAuditTimestamp(value: string): string { + if (!value) return ""; + if (AUDIT_DATE_PATTERN.test(value) && /\b(AM|PM)\b/i.test(value)) return value; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + const datePart = date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + const timePart = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); + return `${datePart} · ${timePart}`; +} diff --git a/src/domain/work-orders/utils/format-board-audit-action.ts b/src/domain/work-orders/utils/format-board-audit-action.ts index 05ebe168..e29cbcbd 100644 --- a/src/domain/work-orders/utils/format-board-audit-action.ts +++ b/src/domain/work-orders/utils/format-board-audit-action.ts @@ -127,6 +127,10 @@ function formatGenericAuditFallback(args: { if (newValue.text) { return `Changed ${label} to ${wrapAuditValue(newValue)}`; } - if (action) return action; + if (oldValue.text) { + return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`; + } + // Never surface a raw audit enum such as "FieldChanged" as the sentence. + if (fieldName && action) return `Updated ${label}`; return "Updated work order"; } diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts index 5de1e4ad..7092c696 100644 --- a/src/domain/work-orders/utils/partition-slide-over-media.ts +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -12,6 +12,11 @@ const EMPTY_OWNERSHIP: MediaSurfaceOwnership = { extraOwnedIds: new Set(), }; +/** Only Reactive and Emergency work orders render a media gallery on the Completion tab. */ +export function hasCompletionMediaGallery(type: string | null | undefined): boolean { + return type === "Emergency" || type === "Reactive"; +} + function isPhotoOrVideo(item: MediaFile): boolean { return item.fileType === "image" || item.fileType === "video"; } @@ -24,20 +29,25 @@ function isInFlight(item: MediaFile): boolean { export function isCompletionSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (isInFlight(item)) return true; if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true; + if (!completionHasGallery && item.purpose === "Extra") return false; if (ownership.completionOwnedIds.has(item.id)) return true; if (ownership.extraOwnedIds.has(item.id)) return false; return item.purpose === "Extra" && isPhotoOrVideo(item); } +/** Without a Completion gallery, every Extra item (including photos/videos) belongs to Extras. */ export function isExtrasSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (item.purpose === "Aveta") return true; if (item.purpose !== "Extra") return false; + if (!completionHasGallery) return true; if (ownership.completionOwnedIds.has(item.id)) return false; if (ownership.extraOwnedIds.has(item.id)) return true; return !isPhotoOrVideo(item); diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts index 264344eb..f0184ef9 100644 --- a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -11,8 +11,21 @@ import { UPLIFT_PENDING_COMPLETED_TITLE, } from "@/domain/work-orders/utils/wo-closability"; -/** AAP `ALL_STATUSES` — Canceled is kebab-only. */ -export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES; +/** + * Lifecycle states the server derives (Incomplete from create, Scheduled from Schedule On). + * Past Due is an overlay, not a status. None of these may be picked manually on an existing WO. + */ +export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"]; + +/** Manually selectable statuses for an existing WO — Canceled is kebab-only. */ +export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter( + (status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status), +); + +/** Inline options for an existing WO never repeat its current status. */ +export function getInlineStatusTransitions(currentStatus: string): WizardWOStatus[] { + return BOARD_INLINE_STATUSES.filter((status) => status !== currentStatus); +} /** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */ export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [ diff --git a/src/domain/work-orders/utils/wo-number-input.ts b/src/domain/work-orders/utils/wo-number-input.ts new file mode 100644 index 00000000..7f3ae624 --- /dev/null +++ b/src/domain/work-orders/utils/wo-number-input.ts @@ -0,0 +1,33 @@ +import { isRealWoNumber } from "@/domain/work-orders/utils/wo-closability"; + +/** Board WO numbers are an 11-digit APM number or an SH placeholder of at most 11 characters. */ +export const BOARD_WO_NUMBER_MAX_LENGTH = 11; + +export const WO_NUMBER_FORMAT_MESSAGE = + "Work order number must be 11 digits or an SH number (e.g. SH00001)"; + +export const WO_NUMBER_DOWNGRADE_MESSAGE = + "Cannot replace an APM work order number with an internal number"; + +export function sanitizeBoardWoNumberInput(raw: string): string { + return raw + .replace(/[^a-zA-Z0-9]/g, "") + .toUpperCase() + .slice(0, BOARD_WO_NUMBER_MAX_LENGTH); +} + +export function isProvisionalWoNumber(woNumber: string): boolean { + return /^SH/i.test(woNumber.trim()); +} + +export function isValidBoardWoNumber(woNumber: string): boolean { + return /^\d{11}$/.test(woNumber) || /^SH\d{1,9}$/i.test(woNumber); +} + +/** + * Only an already-saved real APM number is protected from being replaced by an + * SH placeholder. A brand-new WO has no saved number, so the rule never applies there. + */ +export function isWoNumberDowngrade(savedWoNumber: string, nextWoNumber: string): boolean { + return isRealWoNumber(savedWoNumber) && isProvisionalWoNumber(nextWoNumber); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 36915f91..df63c881 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -56,6 +56,12 @@ export const queryKeys = { byCategory: (category: string, parent?: string) => [...queryKeys.dropdownOptions.all, "byCategory", category, parent ?? ""] as const, }, + services: { + all: ["services"] as const, + active: () => [...queryKeys.services.all, "active"] as const, + byWorkOrderType: (workOrderType: string) => + [...queryKeys.services.all, "byWorkOrderType", workOrderType] as const, + }, locations: { all: ["locations"] as const, list: (params?: Record) => @@ -68,6 +74,8 @@ export const queryKeys = { all: ["vendors"] as const, list: (params?: Record) => [...queryKeys.vendors.all, "list", params ?? {}] as const, + directoryList: (params?: Record) => + [...queryKeys.vendors.all, "directoryList", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const, facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const, dropdown: (trade?: string, siteZip?: string) => diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx new file mode 100644 index 00000000..60444e29 --- /dev/null +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -0,0 +1,178 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LocationContactsFieldArray } from "@/app/(protected)/locations/_components/location-contacts-field-array"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +const dataTransferStub = { + setData: vi.fn(), + effectAllowed: "none" as DataTransfer["effectAllowed"], +}; + +function ContactsHarness({ + initialContacts, + onSubmit = vi.fn(), +}: { + initialContacts: LocationFormValues["contacts"]; + onSubmit?: (values: LocationFormValues) => void; +}) { + const { + control, + handleSubmit, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", + contacts: initialContacts, + }, + }); + + return ( +
+ + + + ); +} + +function nameInputs(): HTMLInputElement[] { + return screen.getAllByLabelText(/^Contact Name/) as HTMLInputElement[]; +} + +/** 1-based handle position: `handleFor(2)` is the second row's drag handle. */ +function handleFor(position: number): HTMLElement { + return screen.getByRole("button", { + name: `Reorder contact ${position}. Use arrow up or down to move.`, + }); +} + +describe("LocationContactsFieldArray", () => { + it("marks the first row as Main and disables removal when only one remains", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Main")).toBeInTheDocument(); + expect(screen.getByLabelText(/^Contact Name \(Main\)/)).toHaveValue("Jane"); + expect(screen.getByLabelText("Remove contact 1")).toBeDisabled(); + }); + + it("adds and removes contact rows", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + + expect(nameInputs()).toHaveLength(2); + expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); + expect(screen.getByText("Main")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Remove contact 2")); + expect(nameInputs()).toHaveLength(1); + expect(screen.getByLabelText("Remove contact 1")).toBeDisabled(); + }); + + it("reorders with keyboard ArrowUp and keeps focus on the moved handle", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(nameInputs().map((input) => input.value)).toEqual(["Jane", "Bob"]); + + const bobHandle = handleFor(2); + bobHandle.focus(); + fireEvent.keyDown(bobHandle, { key: "ArrowUp" }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Bob", "Jane"]); + await waitFor(() => { + expect(document.activeElement).toBe(handleFor(1)); + }); + }); + + it("ignores ArrowDown on the last row", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.keyDown(handleFor(2), { key: "ArrowDown" }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Jane", "Bob"]); + }); + + it("reorders with native drag events from the handle", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.dragStart(handleFor(1), { dataTransfer: dataTransferStub }); + fireEvent.drop(nameInputs()[1].closest("div") as HTMLElement, { + dataTransfer: dataTransferStub, + }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Bob", "Jane"]); + }); + + it("submits contacts in displayed order", async () => { + const onSubmit = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.keyDown(handleFor(2), { key: "ArrowUp" }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + expect(onSubmit.mock.calls[0][0]).toEqual( + expect.objectContaining({ + contacts: [ + { id: 12, name: "Bob", phone: "(421) 433-0033" }, + { id: 11, name: "Jane", phone: "(421) 433-0032" }, + ], + }), + ); + }); +}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx new file mode 100644 index 00000000..e0258626 --- /dev/null +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { LocationsContactsCell } from "@/app/(protected)/locations/_components/locations-contacts-cell"; +import type { LocationListItem } from "@/domain/locations/types/location"; +import { renderWithProviders } from "@/test/test-utils"; + +function rowWithContacts(overrides: Partial): LocationListItem { + return { + id: 1, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + accountId: 7, + ...overrides, + }; +} + +describe("LocationsContactsCell", () => { + it("renders only the main contact name when a single contact exists", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Jane")).toBeInTheDocument(); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it("shows a +N chip and lists remaining contact names on hover", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const chip = screen.getByText("+2"); + expect(chip).toBeInTheDocument(); + expect(screen.getByLabelText("2 more contacts: Bob, Carol")).toBeInTheDocument(); + + fireEvent.mouseOver(chip); + expect(await screen.findByText("Bob, Carol")).toBeInTheDocument(); + }); + + it("falls back to the legacy contact field without a chip", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Jane")).toBeInTheDocument(); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it("renders a placeholder when no contact exists", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index e1bc87d5..68c3fa51 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -27,8 +27,7 @@ function StateSelectHarness({ address: "3811 Distribution Dr", city: "Dallas", zipCode: "70068", - phone: "(421) 433-0032", - contact: "Jane", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", status: "Active", }, diff --git a/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx b/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx new file mode 100644 index 00000000..986f41ad --- /dev/null +++ b/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import VendorPosListPage from "@/app/(protected)/vendor-pos/index"; +import { renderWithProviders } from "@/test/test-utils"; + +const useVendorPosList = vi.fn(); +const useVendorsList = vi.fn(); +const useVendorDirectoryList = vi.fn(); + +vi.mock("@/domain/vendor-pos/use-cases/use-vendor-pos-list", () => ({ + useVendorPosList: (...args: unknown[]) => useVendorPosList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ + useVendorsList: (...args: unknown[]) => useVendorsList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-directory-list", () => ({ + useVendorDirectoryList: (...args: unknown[]) => useVendorDirectoryList(...args), +})); + +describe("VendorPosListPage vendor source", () => { + it("keeps the legacy technician-level vendors list with pageSize 500", () => { + useVendorPosList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }); + useVendorsList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 500 }, + isLoading: false, + isFetching: false, + error: null, + }); + useVendorDirectoryList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 500 }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderWithProviders(, { route: "/vendor-pos", withAuth: false }); + + expect(useVendorsList).toHaveBeenCalledWith({ page: 1, pageSize: 500 }); + expect(useVendorsList).toHaveBeenCalledTimes(1); + expect(useVendorDirectoryList).not.toHaveBeenCalled(); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); +}); 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 3202bd40..031b589e 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 @@ -68,6 +68,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [], }; @@ -134,6 +136,7 @@ function submitValues( state: "MO", zip: "63101", notes: "", + areaId: "", technicians, }; } @@ -300,6 +303,7 @@ describe("useVendorRosterForm prototype defaults", () => { state: "", zip: "", notes: "", + areaId: "", technicians: [ { contactName: "", diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index e96fd031..355f99f1 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -12,6 +12,8 @@ import { type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; import { renderWithProviders } from "@/test/test-utils"; +import type { StateCode } from "@/data/areas"; +import { pickVendorState } from "./vendor-state-select"; vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }), @@ -58,16 +60,18 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); } -function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) { +function fillRequiredAddress({ + street = "6735 Trippel Rd", + city = "Theodore", + state = "AL" as StateCode, +} = {}) { fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), { target: { value: street }, }); fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { target: { value: city }, }); - fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { - target: { value: state }, - }); + pickVendorState(state); } describe("VendorCreateModal validation", () => { 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 8777567d..0da2e959 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -39,6 +39,8 @@ const vendor: VendorListItem = { tradeSpecialties: "", isActive: true, preferredContact: "Phone", + areaId: "", + areaName: "", }; function rosterWith(technicians: Array>) { @@ -57,6 +59,8 @@ function rosterWith(technicians: Array>) { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians, }, isLoading: false, diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx index 28a3b106..2a3bcc81 100644 --- a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx @@ -11,11 +11,15 @@ import { } from "@/domain/vendors/schemas/vendor-roster-schema"; import { suggestAddresses } from "@/lib/address/vendor-address"; import { renderWithProviders } from "@/test/test-utils"; +import { formatStateOptionLabel, normalizeStateCode } from "@/data/areas"; +import { pickVendorState, stateField } from "./vendor-state-select"; const TYPED_STREET = "415 Murphy Rd"; const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0]; if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street"); +const FIRST_SUGGESTION_STATE = normalizeStateCode(FIRST_SUGGESTION.state); +if (FIRST_SUGGESTION_STATE == null) throw new Error("expected a canonical suggestion state"); function AddressFormHarness({ onValid, @@ -67,9 +71,7 @@ describe("VendorRosterFormFields address autocomplete", () => { expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( FIRST_SUGGESTION.city, ); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( - FIRST_SUGGESTION.state, - ); + expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE)); }); it("submits the settled structured parts, not the option label", async () => { @@ -104,14 +106,12 @@ describe("VendorRosterFormFields address autocomplete", () => { expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( FIRST_SUGGESTION.city, ); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( - FIRST_SUGGESTION.state, - ); + expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE)); await user.click(screen.getByRole("button", { name: "Clear" })); expect(street).toHaveValue(""); expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(""); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(""); + expect(stateField()).toHaveValue(""); }); }); @@ -132,9 +132,7 @@ describe("VendorRosterFormFields map preview empty state", () => { fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { target: { value: "Theodore" }, }); - fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { - target: { value: "AL" }, - }); + pickVendorState("AL"); expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); diff --git a/src/test/app/(protected)/vendors/vendor-service-area.test.tsx b/src/test/app/(protected)/vendors/vendor-service-area.test.tsx new file mode 100644 index 00000000..bb19416a --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-service-area.test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { SERVICE_AREA_ADMIN_ONLY_MESSAGE } from "@/app/(protected)/vendors/_components/vendor-service-area-field"; +import { VendorFilterOptions } from "@/app/(protected)/vendors/_components/vendor-filter-options"; +import { + emptyVendorFilters, + serviceAreaFilterOptions, +} from "@/app/(protected)/vendors/_components/vendor-filters"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor"; +import { + mapVendorRosterAdditivePatchToBackend, + mapVendorRosterToBackend, +} from "@/domain/vendors/mappers/vendor-roster-mapper"; +import { mapVendorFacets, mapVendorListItem } from "@/domain/vendors/mappers/vendor-mapper"; +import { renderWithProviders } from "@/test/test-utils"; +import { pickVendorState, stateField } from "./vendor-state-select"; + +const AREAS: VendorFacetArea[] = [ + { id: "1", name: "East" }, + { id: "2", name: "Central" }, +]; + +function Harness({ + canAssignArea, + currentArea = null, + defaults = {}, + onValid, +}: { + canAssignArea: boolean; + currentArea?: VendorFacetArea | null; + defaults?: Partial; + onValid: (values: VendorCompanyRosterFormValues) => void; +}) { + const { control, handleSubmit, formState } = useForm({ + defaultValues: { ...emptyVendorCompanyRosterForm, ...defaults }, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
+ + + + ); +} + +const COMPLETE_COMPANY: Partial = { + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + address: "1 Market St", + city: "St. Louis", + state: "MO", +}; + +describe("Vendor Service Area field", () => { + it("lets an admin assign one Service Area by stable id", async () => { + const onValid = vi.fn(); + renderWithProviders(, { + withAuth: false, + }); + + const area = screen.getByRole("combobox", { name: "Service Area (optional)" }); + expect(area).toBeEnabled(); + fireEvent.change(area, { target: { value: "Cen" } }); + fireEvent.click(screen.getByRole("option", { name: "Central" })); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("2"); + }); + + it("shows the saved Area read-only to non-admins and keeps it on submit", async () => { + const onValid = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + const area = screen.getByRole("combobox", { name: "Service Area (optional)" }); + expect(area).toBeDisabled(); + expect(area).toHaveValue("Archived Zone"); + expect(screen.getByText(SERVICE_AREA_ADMIN_ONLY_MESSAGE)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("9"); + }); + + it("does not derive the Service Area from the selected State", () => { + renderWithProviders(, { withAuth: false }); + + pickVendorState("CA"); + + expect(stateField()).toHaveValue("California (CA)"); + expect(screen.getByRole("combobox", { name: "Service Area (optional)" })).toHaveValue(""); + expect(screen.queryByText(/Area:/)).not.toBeInTheDocument(); + }); +}); + +describe("Vendor State field", () => { + it("is a closed list: free text is not kept and legacy names show as codes", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(stateField()).toHaveValue("Missouri (MO)"); + fireEvent.change(stateField(), { target: { value: "Ontario" } }); + expect(screen.queryByRole("option", { name: /Ontario/ })).not.toBeInTheDocument(); + fireEvent.blur(stateField()); + expect(stateField()).toHaveValue("Missouri (MO)"); + }); +}); + +describe("Service Area contracts", () => { + it("orders filter options Unassigned first, then names A–Z", () => { + expect( + serviceAreaFilterOptions([ + { id: "3", name: "West" }, + { id: "4", name: "california" }, + { id: "1", name: "East" }, + ]).map((option) => option.id), + ).toEqual([VENDOR_AREA_UNASSIGNED, "4", "1", "3"]); + }); + + it("maps facet areas and directory Area fields from the API", () => { + expect( + mapVendorFacets({ + Areas: [ + { Id: 1, Name: "East" }, + { Id: 0, Name: "" }, + ], + }).areas, + ).toEqual([{ id: "1", name: "East" }]); + const row = mapVendorListItem({ Id: 1, AreaId: 2, AreaName: "Central" }); + expect(row.areaId).toBe("2"); + expect(row.areaName).toBe("Central"); + }); + + it("round-trips form Areas, sends explicit null for Unassigned, and omits absent Areas", () => { + expect(mapVendorRosterToBackend({ name: "A", areaId: "3" }).areaId).toBe(3); + expect(mapVendorRosterToBackend({ name: "A", areaId: "" }).areaId).toBeNull(); + expect(mapVendorRosterToBackend({ name: "A" })).not.toHaveProperty("areaId"); + expect( + mapVendorRosterAdditivePatchToBackend({ + rowVersion: "rv", + addTechnicians: [], + companyFields: { areaId: "" }, + }).companyFields, + ).toEqual({ areaId: null }); + expect( + mapVendorRosterAdditivePatchToBackend({ rowVersion: "rv", addTechnicians: [] }), + ).not.toHaveProperty("companyFields"); + }); +}); + +describe("Vendor Service Area filter section", () => { + it("sits between Trade and Location", () => { + const setDraft = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([ + "Company", + "Trade", + "Service Area", + "Location", + "Total Jobs", + ]); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-state-select.ts b/src/test/app/(protected)/vendors/vendor-state-select.ts new file mode 100644 index 00000000..f8694649 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-state-select.ts @@ -0,0 +1,15 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { formatStateOptionLabel, type StateCode } from "@/data/areas"; + +export const STATE_FIELD_NAME = "State (required)"; + +export function stateField(): HTMLElement { + return screen.getByRole("combobox", { name: STATE_FIELD_NAME }); +} + +/** Picks a state from the closed vendor State list by its option label. */ +export function pickVendorState(code: StateCode): void { + const label = formatStateOptionLabel(code); + fireEvent.change(stateField(), { target: { value: label } }); + fireEvent.click(screen.getByRole("option", { name: label })); +} diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index dd0070b8..2b98c7f0 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -5,12 +5,12 @@ import VendorsListPage from "@/app/(protected)/vendors/index"; import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; -const useVendorsList = vi.fn(); +const useVendorDirectoryList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); -vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ - useVendorsList: (...args: unknown[]) => useVendorsList(...args), +vi.mock("@/domain/vendors/use-cases/use-vendor-directory-list", () => ({ + useVendorDirectoryList: (...args: unknown[]) => useVendorDirectoryList(...args), })); vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ @@ -46,6 +46,8 @@ const activeVendor: VendorListItem = { tradeSpecialties: "Backflow Preventers", isActive: true, preferredContact: "Email", + areaId: "", + areaName: "", }; const inactiveVendor = { @@ -67,6 +69,8 @@ const activeRoster: VendorCompanyRoster = { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [ { id: 1, @@ -108,15 +112,22 @@ function setupDefaults() { describe("VendorsListPage", () => { 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); - return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); + return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); - expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Company Phone" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Company Email" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Street Address" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Technician Phone" })).toBeInTheDocument(); + expect(screen.getByText("St. Louis")).toBeInTheDocument(); + expect(screen.getByText("MO")).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); expect( @@ -134,7 +145,58 @@ describe("VendorsListPage", () => { expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); - it("prefers trimmed city and state over address and technician phone over company phone", () => { + it("renders one company row with the primary technician and a keyboard-accessible roster badge", async () => { + setupDefaults(); + const groupedVendor: VendorListItem = { + ...activeVendor, + id: 22, + contactName: "Promoted Technician", + totalJobs: 9, + technicians: [ + { + id: 11, + contactName: "Older Technician", + email: "older@gateway.test", + phone: "314-555-0111", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 4, + }, + { + id: 22, + contactName: "Promoted Technician", + email: "promoted@gateway.test", + phone: "314-555-0222", + preferredContact: "Email", + tradeSpecialties: "HVAC", + isActive: true, + totalJobs: 5, + }, + ], + }; + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([groupedVendor], 1) : result([], 0); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getAllByText("Gateway Plumbing")).toHaveLength(1); + expect(screen.getByText("Promoted Technician")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "314-555-0222" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "9" })).toBeInTheDocument(); + + const rosterBadge = screen.getByTestId("vendor-technician-badge"); + expect(rosterBadge).toHaveTextContent("+1"); + expect(rosterBadge).toHaveAttribute("tabindex", "0"); + await userEvent.hover(rosterBadge); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Older Technician"); + }); + + it("renders company location and contact fields in separate columns", () => { setupDefaults(); const vendorWithAllFields: VendorListItem = { ...activeVendor, @@ -144,21 +206,29 @@ describe("VendorsListPage", () => { state: " MO ", phone: " (314) 555-0198 ", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([vendorWithAllFields], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([vendorWithAllFields], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.queryByText(/1 Market St/)).not.toBeInTheDocument(); - const phoneLink = screen.getByRole("link", { name: "(314) 555-0198" }); - expect(phoneLink).toHaveAttribute("href", "tel:(314) 555-0198"); - expect(screen.queryByText("(314) 555-0100")).not.toBeInTheDocument(); + expect(screen.getByText("1 Market St")).toBeInTheDocument(); + expect(screen.getByText("St. Louis")).toBeInTheDocument(); + expect(screen.getByText("MO")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "(314) 555-0198" })).toHaveAttribute( + "href", + "tel:(314) 555-0198", + ); + expect(screen.getByRole("link", { name: "(314) 555-0100" })).toHaveAttribute( + "href", + "tel:(314) 555-0100", + ); }); - it("falls back to the trimmed address and company phone when technician fields are absent", () => { + it("keeps company and technician phone ownership separate when technician phone is absent", () => { setupDefaults(); const fallbackVendor: VendorListItem = { ...activeVendor, @@ -168,10 +238,12 @@ describe("VendorsListPage", () => { state: "", phone: " ", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([fallbackVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -179,6 +251,7 @@ describe("VendorsListPage", () => { const companyPhoneLink = screen.getByRole("link", { name: "(314) 555-0100" }); expect(companyPhoneLink).toHaveAttribute("href", "tel:(314) 555-0100"); expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument(); + expect(screen.getByTestId("vendor-text-technician-phone")).toHaveTextContent("—"); }); it("truncates every textual table field and reveals each full value", async () => { @@ -199,19 +272,21 @@ describe("VendorsListPage", () => { city: "", state: "", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([fallbackVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); const fields = [ { testId: "vendor-text-company", value: longCompany }, { testId: "vendor-text-technician", value: longTechnician }, - { testId: "vendor-text-location", value: longAddress }, - { testId: "vendor-text-phone", value: longPhone }, - { testId: "vendor-text-email", value: longEmail }, + { testId: "vendor-text-street-address", value: longAddress }, + { testId: "vendor-text-technician-phone", value: longPhone }, + { testId: "vendor-text-company-email", value: longEmail }, ]; for (const field of fields) { @@ -231,10 +306,18 @@ describe("VendorsListPage", () => { await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); } - expect(screen.getByTestId("vendor-text-phone")).toHaveAttribute("href", `tel:${longPhone}`); - expect(screen.getByTestId("vendor-text-email")).toHaveAttribute("href", `mailto:${longEmail}`); + expect(screen.getByTestId("vendor-text-technician-phone")).toHaveAttribute( + "href", + `tel:${longPhone}`, + ); + expect(screen.getByTestId("vendor-text-company-email")).toHaveAttribute( + "href", + `mailto:${longEmail}`, + ); + fireEvent.keyDown(screen.getByTestId("vendor-text-company-email"), { key: "Enter" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - const locationText = screen.getByTestId("vendor-text-location"); + const locationText = screen.getByTestId("vendor-text-street-address"); fireEvent.keyDown(document, { key: "Tab" }); locationText.focus(); expect(locationText).toHaveFocus(); @@ -250,10 +333,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -277,10 +362,12 @@ describe("VendorsListPage", () => { it("does not show the +N badge when the vendor has a single trade", () => { setupDefaults(); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.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 }); @@ -301,10 +388,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -325,10 +414,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -345,15 +436,17 @@ describe("VendorsListPage", () => { companyName: `Vendor ${index + 1}`, })); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); - return result(pageVendors, 25); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); + return result(pageVendors, 25); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/25 active companies/)).not.toHaveLength(0); + expect(screen.queryByText(/3 active companies/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { @@ -365,10 +458,12 @@ describe("VendorsListPage", () => { 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); - }); + useVendorDirectoryList.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 }); @@ -406,10 +501,12 @@ describe("VendorsListPage", () => { 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); - }); + useVendorDirectoryList.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 }); @@ -447,10 +544,12 @@ describe("VendorsListPage", () => { 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); - }); + useVendorDirectoryList.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 }); @@ -477,10 +576,12 @@ describe("VendorsListPage", () => { 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); - }); + useVendorDirectoryList.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 }); @@ -508,10 +609,12 @@ describe("VendorsListPage", () => { 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); - }); + useVendorDirectoryList.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 }); @@ -533,10 +636,12 @@ describe("VendorsListPage", () => { 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); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -547,4 +652,50 @@ describe("VendorsListPage", () => { "Gateway Plumbing", ); }); + + it("loads rows and status counts through the directory hook", async () => { + setupDefaults(); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 3 : 2); + return result([activeVendor], 3); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(useVendorDirectoryList).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, pageSize: 48 }), + ); + expect(useVendorDirectoryList).toHaveBeenCalledWith({ isActive: true, page: 1, pageSize: 1 }); + expect(useVendorDirectoryList).toHaveBeenCalledWith({ isActive: false, page: 1, pageSize: 1 }); + expect(screen.getByRole("tab", { name: "Active (3)" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Inactive (2)" })).toBeInTheDocument(); + }); + + it("shows the Service Area column after State with Unassigned for companies without an Area", () => { + setupDefaults(); + const assigned: VendorListItem = { ...activeVendor, areaId: "1", areaName: "East" }; + const unassigned: VendorListItem = { + ...activeVendor, + id: 9, + companyId: "co-9", + companyName: "No Area Co", + }; + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 2); + return params.isActive ? result([assigned, unassigned], 2) : result([], 0); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + const headers = screen.getAllByRole("columnheader").map((header) => header.textContent); + expect(headers.indexOf("Service Area")).toBe(headers.indexOf("State") + 1); + expect(headers.indexOf("Trade")).toBe(headers.indexOf("Service Area") + 1); + expect( + screen.getAllByTestId("vendor-text-service-area").map((cell) => cell.textContent), + ).toEqual(["East", "Unassigned"]); + }); }); diff --git a/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx new file mode 100644 index 00000000..e6c41d50 --- /dev/null +++ b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx @@ -0,0 +1,194 @@ +import { fireEvent, render, renderHook, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({ + SiteCell: () => null, +})); +vi.mock( + "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell", + () => ({ EditableWONumberCell: () => null }), +); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({ + rescheduleNotify: vi.fn(), + weekMoveNotify: vi.fn(), +})); + +function day(n: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(n)}`; +} + +function rowByLabel(label: string) { + return screen.getByText(label, { selector: "span,label,div" }).closest("div")!; +} + +describe("Due Date cannot be before Schedule On (SH-356)", () => { + it("disables calendar days before the floor", () => { + render(); + + expect(screen.getByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "11" })).toBeEnabled(); + }); + + describe("slide-over edit", () => { + const draft = { scheduledOn: day(10), dueDate: day(12) } as Partial; + + function renderSlideOver(onDraftChange = vi.fn()) { + renderWithProviders( + , + ); + return onDraftChange; + } + + it("disables Due Date days before Schedule On", async () => { + renderSlideOver(); + fireEvent.click(within(rowByLabel("Due Date")).getByRole("button")); + + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + }); + + it("clears the Due Date when Schedule On moves past it", async () => { + const onDraftChange = renderSlideOver(); + fireEvent.click(within(rowByLabel("Schedule On")).getAllByRole("button")[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + }); + + it("wizard: clears Due Date on a later Schedule On and floors the Due Date picker", async () => { + const onPatch = vi.fn(); + render( + , + ); + + const dueDateButtons = within(rowByLabel("Due Date").parentElement!).getAllByRole("button"); + fireEvent.click(dueDateButtons[dueDateButtons.length - 1]); + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + + const scheduleBlock = rowByLabel("Schedule On").parentElement!; + const scheduleButtons = within(scheduleBlock).getAllByRole("button"); + fireEvent.click(scheduleButtons[scheduleButtons.length - 1]); + const calendars = await screen.findAllByRole("button", { name: "15" }); + fireEvent.click(calendars[calendars.length - 1]); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("inline creation row: clears Due Date on a later Schedule On", async () => { + const onChange = vi.fn(); + renderWithProviders( +
+ {children} + + onPatch({ type: t, severity: sev })} /> - - — + +
+ — +
+ onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, date), scheduledOn: date, scheduleWeekOnly: false, targetWeek: "", @@ -105,6 +123,7 @@ function InlineScheduleOnCell({ onSaveWeek={(week) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, week), scheduledOn: "", targetWeek: week, scheduleWeekOnly: true, @@ -117,6 +136,7 @@ function InlineScheduleOnCell({ onSaveRange={(start, end) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -142,7 +162,7 @@ function InlineScheduleOnCell({ : undefined } /> - + { onPatch({ dispatcherId: id, status: draft.status || "Incomplete" }); }} /> - + onPatch({ dueDate: date })} /> - + + - + + onPatch(toVendorTablePatch(p))} /> - + + - + + {}} onRequestComplete={onRequestComplete} /> - + - - +
+
+ handlers.onScheduleDate(row.id, date)} - onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)} - onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)} + onSave={(date) => handlers.onScheduleDate(row.id, date, row.dueDate)} + onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week, row.dueDate)} + onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end, row.dueDate)} onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined} /> e.stopPropagation()} + > + onPatchRow({ completedDate: date })} + /> + e.stopPropagation()}> e.stopPropagation()} - > - onPatchRow({ completedDate: date })} - /> -
+ + + +
, + { withAuth: false }, + ); + + const cells = screen.getAllByRole("cell"); + const dateButtons = cells.flatMap((cell) => within(cell).queryAllByRole("button")); + const scheduleButton = dateButtons.find( + (b) => b.textContent?.includes(day(10).slice(5).replace("-", "/")) ?? false, + ); + fireEvent.click(scheduleButton ?? dateButtons[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("board row: sends the Due Date clear in the same schedule patch", () => { + const onPatch = vi.fn(); + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: { + dragState: { current: { dragId: null, overId: null } }, + setDragViz: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + revealScheduledRow: vi.fn(), + setSelectedId: vi.fn(), + } as never, + weekMonday: "2026-05-18", + onNavigateToDay: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onScheduleDate("wo-1", "2026-05-21", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith("wo-1", expect.objectContaining({ dueDate: "" })); + + result.current.onScheduleRange("wo-1", "2026-05-19", "2026-05-22", "2026-05-20"); + expect(onPatch.mock.lastCall?.[1]).not.toHaveProperty("dueDate"); + + result.current.onScheduleWeek("wo-1", "2026-05-25", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith( + "wo-1", + expect.objectContaining({ targetWeek: "2026-05-25", dueDate: "" }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx new file mode 100644 index 00000000..ad16d2e8 --- /dev/null +++ b/src/test/app/(protected)/workorders/editable-wo-number-cell-sh-placeholder.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => { + const toastFn = Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }); + return { toast: toastFn }; +}); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { findByWoNumber: vi.fn() }, +})); + +function renderCell(value: string, currentId: string | number) { + const onSave = vi.fn(); + renderWithProviders( + , + ); + return onSave; +} + +function typeAndCommit(nextValue: string) { + fireEvent.click(screen.getByText(/./, { selector: "span" })); + const input = screen.getByLabelText("Work order number"); + fireEvent.change(input, { target: { value: nextValue } }); + fireEvent.keyDown(input, { key: "Enter" }); +} + +describe("EditableWONumberCell SH placeholder numbers", () => { + beforeEach(() => { + vi.mocked(workOrdersApi.findByWoNumber).mockResolvedValue(null as never); + vi.mocked(toast).mockClear(); + vi.mocked(toast.error).mockClear(); + }); + + it("saves a freshly typed SH number on a brand-new inline work order", async () => { + const onSave = renderCell("", "draft"); + + typeAndCommit("SH00001"); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00001")); + expect(toast).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("keeps a typed SH prefix instead of stripping it to digits", () => { + renderCell("", "draft"); + + fireEvent.click(screen.getByText("-")); + const input = screen.getByLabelText("Work order number"); + fireEvent.change(input, { target: { value: "sh00001" } }); + + expect(input).toHaveValue("SH00001"); + }); + + it("does not treat an unsaved inline draft number as a real number to protect", async () => { + const onSave = renderCell("12345678901", "draft"); + + typeAndCommit("SH00002"); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("SH00002")); + }); + + it("blocks replacing a saved real APM number with an SH number", async () => { + const onSave = renderCell("12345678901", 42); + + typeAndCommit("SH00001"); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "Cannot replace an APM work order number with an internal number", + ), + ); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText("12345678901")).toBeInTheDocument(); + }); + + it("lets a saved SH number be replaced by a real 11-digit number", async () => { + const onSave = renderCell("SH00001", 42); + + typeAndCommit("12345678901"); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("12345678901")); + }); + + it("still rejects a numeric number that is not 11 digits", () => { + const onSave = renderCell("", "draft"); + + typeAndCommit("12345"); + + expect(toast).toHaveBeenCalledWith( + "Work order number must be 11 digits or an SH number (e.g. SH00001)", + ); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 0ca3f3c9..86b6020c 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -42,6 +42,16 @@ describe("saveExistingRowType", () => { expect(onPatch.mock.calls[0]![2]).toBeUndefined(); }); + it("does not warn when moving between SEV 3–5", () => { + const onPatch = vi.fn(); + + saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3); + + expect(toastWarn).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 }); + expect(onPatch.mock.calls[0]![2]).toBeUndefined(); + }); + it("does not warn when clearing severity on a PM correction", () => { const onPatch = vi.fn(); diff --git a/src/test/app/(protected)/workorders/service-picker-search-placeholder.test.tsx b/src/test/app/(protected)/workorders/service-picker-search-placeholder.test.tsx new file mode 100644 index 00000000..7a2d7fa1 --- /dev/null +++ b/src/test/app/(protected)/workorders/service-picker-search-placeholder.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select"; +import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +describe("service picker search placeholder", () => { + it("uses a type-neutral search placeholder in the wizard service picker", () => { + const draft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, pm: "" }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /select service/i })); + + expect(screen.getByPlaceholderText("Search services…")).toBeTruthy(); + expect(screen.queryByPlaceholderText("Search PM type…")).toBeNull(); + }); + + it("uses a type-neutral search placeholder in the table service picker popover", () => { + const anchor = document.createElement("button"); + + render( + , + ); + + expect(screen.getByPlaceholderText("Search service…")).toBeTruthy(); + expect(screen.queryByPlaceholderText("Search PM type…")).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/severity-badge.test.tsx b/src/test/app/(protected)/workorders/severity-badge.test.tsx new file mode 100644 index 00000000..2beb69ef --- /dev/null +++ b/src/test/app/(protected)/workorders/severity-badge.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SeverityBadge } from "@/app/(protected)/workorders/_components/list/table/cells/severity-badge"; + +describe("SeverityBadge", () => { + it("shows description, Accept/Respond deadline, and after-hours on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 1")); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "Business Critical Function Down · Accept: 15 min · Respond: 2 hrs · After hours: Yes", + ); + expect(tooltip).not.toHaveTextContent(/Quote|Tech Notes|Invoice|Turnaround/i); + + await user.unhover(screen.getByText("SEV 1")); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + }); + + it("transcribes the SEV table row for the given severity", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 3")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Group Productivity Impaired · Accept: 2 hrs · Respond: 8 hrs (next day if after hours) · After hours: Approval required", + ); + }); + + it("is a button only when it can reopen the severity control", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + rerender(); + const badge = screen.getByRole("button"); + expect(badge).toHaveTextContent("SEV 2"); + await user.click(badge); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx b/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx new file mode 100644 index 00000000..ac8d7393 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list"; +import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order"; + +function statusEntry(overrides: Partial = {}): WorkOrderAuditEntry { + return { + id: "s1", + fieldName: "LifecycleStatus", + oldValue: "Pending", + newValue: "En Route", + action: "StatusChanged", + createdAt: "2026-09-15T19:42:00", + userName: "Alex Dispatcher", + type: "manual", + ...overrides, + }; +} + +describe("SlideOverAuditList timestamps (SH-361)", () => { + it("shows the date as well as the time on a manual status change", () => { + render(); + + expect(screen.getByText('Changed status from "Pending" to "En Route"')).toBeInTheDocument(); + expect(screen.getByText(/^\d{2}\/\d{2}\/2026 · 7:42 PM$/)).toBeInTheDocument(); + }); + + it("renders a type correction with its previous value (SH-363)", () => { + render( + , + ); + + expect(screen.getByText('Changed Type from "PM" to "Reactive"')).toBeInTheDocument(); + expect(screen.getByText('Changed Severity from "SEV 3" to "—"')).toBeInTheDocument(); + expect(screen.queryByText("FieldChanged")).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx new file mode 100644 index 00000000..d87162fe --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) { + const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow; + render( + , + ); + return onOpenCompletionDoc; +} + +describe("SlideOverCompletionTab service gate", () => { + it("disables the completion document button until a service is selected", () => { + const onOpen = renderTab(" "); + + const button = screen.getByRole("button", { name: /select service/i }); + expect(button).toBeDisabled(); + expect( + screen.getByText("Select a service before opening the completion document."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /open completion document/i }), + ).not.toBeInTheDocument(); + + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("opens the completion document when a service is selected", () => { + const onOpen = renderTab("HVAC Inspection"); + + const button = screen.getByRole("button", { name: /open completion document/i }); + expect(button).toBeEnabled(); + expect(screen.queryByText(/select service/i)).not.toBeInTheDocument(); + + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index f788e250..ba674966 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -161,4 +161,40 @@ describe("SlideOverExtrasTab Aveta", () => { expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled(); }); + + it("labels each extra item with its type and category", () => { + const sitePhoto: MediaFile = { + id: "m3", + name: "site.jpg", + fileType: "image", + url: "blob:3", + category: "Photo", + purpose: "Extra", + }; + const walkthrough: MediaFile = { + id: "m4", + name: "walkthrough.mp4", + fileType: "video", + url: "blob:4", + category: "Video", + purpose: "Extra", + }; + + renderWithProviders( + , + ); + + expect(screen.getByRole("group", { name: "Photo, Extra: site.jpg" })).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Video, Extra: walkthrough.mp4" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "PDF, Aveta: aveta-clearance.pdf" }), + ).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx index 31c90363..7f42d257 100644 --- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell"; +import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; const incompleteRow = { @@ -43,21 +44,62 @@ describe("StatusCell closability gate", () => { expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull(); }); - it("disables Scheduled when the row has no concrete date", () => { + it.each([ + "Pending", + "En Route", + "On Site", + "In Progress", + "Rescheduled", + "Pending Quote", + ])("offers only manual transitions away from %s (SH-357, SH-358)", (current) => { const onChangeStatus = vi.fn(); render( , ); - fireEvent.click(screen.getByRole("button", { name: /pending/i })); - const scheduled = screen.getByRole("button", { name: /^scheduled$/i }); - expect(scheduled).toBeDisabled(); - fireEvent.click(scheduled); - expect(onChangeStatus).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") })); + const offered = Array.from(document.querySelectorAll(".MuiPopover-paper button")).map( + (button) => button.textContent?.trim(), + ); + + expect(offered).not.toContain("Incomplete"); + expect(offered).not.toContain("Scheduled"); + expect(offered).not.toContain("Past Due"); + expect(offered).not.toContain("Canceled"); + expect(offered).not.toContain(current); + expect(offered).toEqual( + [ + "Pending", + "En Route", + "On Site", + "In Progress", + "Completed", + "Rescheduled", + "Pending Quote", + ].filter((status) => status !== current), + ); + }); + + it("offers every manual status, and no automatic one, from Incomplete or Scheduled", () => { + for (const current of ["Incomplete", "Scheduled"] as const) { + const { unmount } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") })); + expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^incomplete$/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull(); + unmount(); + } }); it("limits creation options to Incomplete, Pending, and Scheduled", () => { diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 87a74de5..dab9e3bd 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -278,4 +278,53 @@ describe("useSlideOverMediaState completion filter", () => { expect(remounted.result.current.completionMedia).toHaveLength(0); expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); }); + + it("shows Extra photos and videos in Extra Docs on PM work orders", () => { + const detail = detailWithMedia( + [ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "11", + category: "Extra", + url: "https://cdn.example.com/walkthrough.mp4", + fileName: "walkthrough.mp4", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "4", + category: "Extra", + url: "https://cdn.example.com/extra.pdf", + fileName: "extra.pdf", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ], + { id: 200, type: "PM" }, + ); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10", "11", "4"]); + expect(result.current.extrasUploads.map((item) => item.fileType)).toEqual([ + "image", + "video", + "pdf", + ]); + }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts new file mode 100644 index 00000000..60c004c7 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const patchField = vi.hoisted(() => vi.fn()); +const patchFieldAsync = vi.hoisted(() => vi.fn()); + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen: false, + setAdvOpen: vi.fn(), + advApplied: null, + advInitial: EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: vi.fn(), + clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, + weekMonday: "2026-07-20", + selectedDayIso: "2026-07-20", + setWeekMonday: vi.fn(), + setSelectedDayIso: vi.fn(), + navigateToDay: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + types: new Set(), + setTypes: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ + patchField, + patchFieldAsync, + isPending: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [] }), +})); + +import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; + +function closableRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 77, + woNumber: "24866", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + company: "Vendor Co", + vendorId: "7", + tech: "Tech One", + completedDate: "2026-09-15", + pm: "HVAC", + status: "In Progress", + docStatus: "No", + mediaCount: 0, + isPastDue: false, + rowVersion: "v1", + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + } as WorkOrderTableRow; +} + +describe("useWorkOrdersListPage completion document (SH-341)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("completes the WO without confirmation once Generate persists the document", async () => { + const saved = closableRow({ docStatus: "Yes", rowVersion: "v2" }); + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: saved }); + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: { ...saved, status: "Completed" }, + }); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + + expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" }); + expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved); + expect(patchField).not.toHaveBeenCalled(); + expect(result.current.confirmComplete).toBeNull(); + }); + + it("does not complete on Save draft or Not required", async () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "No" }) }); + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "No"); + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "NN" }) }); + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "NN"); + + expect(patchFieldAsync).toHaveBeenCalledTimes(2); + expect(patchFieldAsync).not.toHaveBeenCalledWith( + 77, + { status: "Completed" }, + expect.anything(), + ); + }); + + it("keeps the closability gate: a missing service leaves the status unchanged", async () => { + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: closableRow({ docStatus: "Yes", pm: "" }), + }); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + + expect(patchFieldAsync).toHaveBeenCalledTimes(1); + }); + + it("keeps the generated document when the automatic completion is rejected", async () => { + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: closableRow({ docStatus: "Yes" }), + }); + patchFieldAsync.mockRejectedValueOnce(new Error("Work order was modified.")); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await expect( + result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"), + ).resolves.toBeUndefined(); + expect(patchFieldAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx index c227d9d0..fc40c3d8 100644 --- a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx @@ -81,6 +81,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( expect(onPatch).toHaveBeenCalledWith({ pm: "Electrical", + serviceId: "3", extraServices: [], vendorId: "", vendorName: "", @@ -120,6 +121,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( expect(onPatch).toHaveBeenCalledWith({ pm: "Plumbing", + serviceId: "2", extraServices: [], vendorId: "", vendorName: "", @@ -154,6 +156,6 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( fireEvent.click(screen.getByRole("button", { name: "HVAC" })); fireEvent.click(screen.getByRole("button", { name: "HVAC" })); - expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", extraServices: [] }); + expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", serviceId: "1", extraServices: [] }); }); }); diff --git a/src/test/app/(protected)/workorders/wizard-step-type-schedule-clears-service.test.tsx b/src/test/app/(protected)/workorders/wizard-step-type-schedule-clears-service.test.tsx new file mode 100644 index 00000000..9c9582ee --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-step-type-schedule-clears-service.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +const USERS = [{ id: "sarah", name: "Sarah Chen" }]; + +describe("WizardStepTypeSchedule — service clear on type change", () => { + it("clears the service and its vendor when the work-order type changes", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM", + pm: "HVAC", + serviceId: "1", + extraServices: ["Plumbing"], + vendorId: "9", + vendorName: "Old Vendor", + techPhone: "555-0100", + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Reactive/ })); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "Reactive", + pm: "", + serviceId: null, + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + }), + ); + }); + + it("keeps the selected service when the same type is re-selected", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM", + pm: "HVAC", + serviceId: "1", + extraServices: ["Plumbing"], + vendorId: "9", + vendorName: "Old Vendor", + techPhone: "555-0100", + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Scheduled preventive maintenance/ })); + + const patch = onPatch.mock.calls[0][0] as Partial; + expect(patch).not.toHaveProperty("pm"); + expect(patch).not.toHaveProperty("serviceId"); + expect(patch).not.toHaveProperty("extraServices"); + expect(patch).not.toHaveProperty("vendorId"); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx b/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx new file mode 100644 index 00000000..aeee1da9 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx @@ -0,0 +1,116 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols"; +import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => cell:service, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => cell:vendor, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => cell:appt, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => cell:status, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => cell:completed, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({ + CompDocCell: () => cell:doc, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({ + UpliftCell: () => cell:uplift, +})); + +/** Documented left-to-right data column order for the Work Orders board. */ +const DOCUMENTED_ORDER = [ + "SITE", + "WO", + "TYPE OF WO", + "ASSIGNED TO", + "SCHEDULE ON", + "DUE DATE", + "SERVICE", + "VENDOR", + "APPT TIME", + "STATUS", + "COMPLETED DATE", + "COMP DOC", + "UPLIFT", +]; + +describe("Work Orders board column order", () => { + it("renders the documented header order with grip/flag before Site and actions after Uplift", () => { + renderWithProviders( + + +
, + { withAuth: false }, + ); + + const headers = screen.getAllByRole("columnheader").map((th) => th.textContent?.trim() ?? ""); + expect(headers.filter(Boolean)).toEqual(expect.arrayContaining(DOCUMENTED_ORDER)); + expect(WO_TABLE_COLS.map((col) => col.key)).toEqual([ + "grip", + "flag", + "site", + "wo", + "type", + "assigned", + "sched", + "due", + "pm", + "vendor", + "appt", + "status", + "completed", + "doc", + "uplift", + "actions", + ]); + expect(WO_TABLE_COLS.map((col) => col.label).filter(Boolean)).toEqual(DOCUMENTED_ORDER); + }); + + it("renders row cells under the matching headers: Status, Completed Date, Comp Doc, Uplift", () => { + const row = { + id: 7, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + } as WorkOrderTableRow; + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + const cells = within(screen.getByTestId("row")) + .getAllByRole("cell") + .map((td) => td.textContent); + expect(cells).toEqual([ + "cell:service", + "cell:vendor", + "cell:appt", + "cell:status", + "cell:completed", + "cell:doc", + "cell:uplift", + ]); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-head-date-tooltips.test.tsx b/src/test/app/(protected)/workorders/wo-table-head-date-tooltips.test.tsx new file mode 100644 index 00000000..a25bd953 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-head-date-tooltips.test.tsx @@ -0,0 +1,71 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head"; +import { getWoTableHeaderTooltip } from "@/domain/work-orders/constants/wo-table-header-tooltips"; +import { renderWithProviders } from "@/test/test-utils"; + +const CASES = [ + { + columnName: "SCHEDULE ON", + tooltip: + "The date the work is planned to happen. Setting it moves the work order to Scheduled.", + }, + { + columnName: "DUE DATE", + tooltip: "The deadline for the work — when it must be completed by.", + }, + { + columnName: "COMPLETED DATE", + tooltip: "The date the vendor actually finished the work. Required to complete the work order.", + }, +]; + +function renderHead() { + return renderWithProviders( + + +
, + { withAuth: false }, + ); +} + +describe("WoTableHead date column header tooltips (SH-195)", () => { + it.each(CASES)( + "shows the $columnName help icon and tooltip copy on hover", + async ({ columnName, tooltip }) => { + const user = userEvent.setup(); + renderHead(); + + const header = screen.getByRole("columnheader", { name: new RegExp(`^${columnName}`) }); + const icon = within(header).getByRole("button", { name: `${columnName} column help` }); + + await user.hover(icon); + + const tip = await screen.findByText(tooltip); + expect(tip).toBeInTheDocument(); + }, + 15000, + ); + + it("moves focus to the help icon on tab and keeps it keyboard reachable", async () => { + const user = userEvent.setup(); + renderHead(); + + const header = screen.getByRole("columnheader", { name: /^SCHEDULE ON/ }); + const icon = within(header).getByRole("button", { name: "SCHEDULE ON column help" }); + + await user.tab(); + + expect(icon).toHaveFocus(); + }); + + it("does not add a help icon to Appt Time (open point, not implemented)", () => { + renderHead(); + + const header = screen.getByRole("columnheader", { name: "APPT TIME" }); + expect(within(header).queryByRole("img")).not.toBeInTheDocument(); + expect(getWoTableHeaderTooltip("appt")).toBeUndefined(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx new file mode 100644 index 00000000..f5817e84 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { + EMPTY_TABLE_DRAFT, + type WorkOrderTableDraft, +} from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({ + DueDateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); + +function renderInlineRow(draft: WorkOrderTableDraft, onSave = vi.fn()) { + const ui = (d: WorkOrderTableDraft) => ( + + + + +
+ ); + const view = renderWithProviders(ui(draft), { withAuth: false }); + return { ...view, rerenderDraft: (d: WorkOrderTableDraft) => view.rerender(ui(d)) }; +} + +const COMPLETE_DRAFT: WorkOrderTableDraft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: 9, + woNumber: "12345678901", + pocName: "Dana", + pocPhone: "555-0100", +}; + +describe("WoTableInlineRow required fields", () => { + it("marks Site and WO # as required without showing errors before a Save attempt", () => { + renderInlineRow(EMPTY_TABLE_DRAFT); + + expect(screen.getByRole("button", { name: /^Site \*$/ })).toBeInTheDocument(); + expect(screen.getByText("Insert WO #")).toHaveTextContent("Insert WO # *"); + expect(screen.queryByText("Site is required")).not.toBeInTheDocument(); + expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument(); + }); + + it("reveals field errors only after Save is attempted, then clears them once corrected", () => { + const onSave = vi.fn(); + const { rerenderDraft } = renderInlineRow(EMPTY_TABLE_DRAFT, onSave); + + fireEvent.click(screen.getByRole("button", { name: "Save work order" })); + + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText("Site is required")).toBeInTheDocument(); + expect(screen.getByText("Work order number is required")).toBeInTheDocument(); + + rerenderDraft(COMPLETE_DRAFT); + + expect(screen.queryByText("Site is required")).not.toBeInTheDocument(); + expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save work order" })); + expect(onSave).toHaveBeenCalledTimes(1); + }); +}); + +describe("PocContactFields required primary contact", () => { + it("labels POC name and phone with a required asterisk when the contact is required", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByLabelText(/POC name/)).toHaveAttribute("placeholder", "POC name"); + expect(screen.getByLabelText(/POC phone/)).toHaveAttribute("placeholder", "POC phone"); + expect(screen.getByText("POC name").closest("label")).toHaveTextContent("POC name*"); + expect(screen.getByText("POC phone").closest("label")).toHaveTextContent("POC phone*"); + expect(screen.queryByText("Contact name is required")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index c3fb07b9..7e4df369 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -189,7 +189,12 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ describe("WorkOrdersListHeader slice affordances", () => { it("hides New WO and Advanced Filters when callbacks are omitted", () => { renderWithProviders( - , + , { withAuth: false }, ); @@ -202,6 +207,7 @@ describe("WorkOrdersListHeader slice affordances", () => { renderWithProviders( ({ + searchBoard: vi.fn(), + getBoard: vi.fn(), + emptyList: [] as const, +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: T) => value, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + getBoard: (...args: unknown[]) => getBoard(...args), + }, +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: emptyList }), + useWorkOrderVendors: () => ({ data: emptyList }), +})); + +import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; +import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; + +const EMPTY_RESULT = { items: [], totalCount: 0, page: 0, pageSize: 25 }; + +function useBoardWithFilters() { + const filters = useWorkOrdersListFilters(); + useWorkOrdersBoardSource(filters); + return filters; +} + +function renderBoardSource() { + const client = createTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useBoardWithFilters(), { wrapper }); +} + +describe("keyword search scope indicator", () => { + it("shows no scope badge while the search box is empty", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); + + it("names the active week while a keyword is typed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Searching within 5/4–5/8")).toBeInTheDocument(); + }); + + it("hides the scope label while Advanced Filters are active", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); +}); + +describe("keyword search vs Advanced Filters scope", () => { + beforeEach(() => { + searchBoard.mockReset().mockResolvedValue(EMPTY_RESULT); + getBoard.mockReset().mockResolvedValue(EMPTY_RESULT); + }); + + it("scopes keyword search to the active week", async () => { + const { result } = renderBoardSource(); + + act(() => { + result.current.setWeekMonday("2026-05-04"); + result.current.handleSearchChange("hvac"); + }); + + await waitFor(() => + expect(getBoard).toHaveBeenCalledWith( + expect.objectContaining({ weekStart: "2026-05-04", weekEnd: "2026-05-08", search: "hvac" }), + ), + ); + expect(searchBoard).not.toHaveBeenCalled(); + }); + + it("keeps Advanced Filters global and ignores the week-scoped keyword", async () => { + const { result } = renderBoardSource(); + + act(() => { + result.current.handleSearchChange("hvac"); + result.current.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, rescheduled: true }); + }); + + await waitFor(() => expect(searchBoard).toHaveBeenCalled()); + const params = searchBoard.mock.lastCall?.[0] as Record; + expect(params.search).toBeUndefined(); + expect(params).not.toHaveProperty("weekStart"); + }); +}); diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts new file mode 100644 index 00000000..52d27cc6 --- /dev/null +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + mapLocation, + mapLocationListItem, + mapLocationToBackend, +} from "@/domain/locations/mappers/location-mapper"; + +describe("location contacts hydration", () => { + it("maps the response contacts array and derives the main contact fields", () => { + const item = mapLocationListItem({ + id: 1, + name: "Dallas DC", + contacts: [ + { name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ], + }); + + expect(item.contacts).toEqual([ + { name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ]); + expect(item.contact).toBe("Jane"); + expect(item.phone).toBe("(421) 433-0032"); + }); + + it("falls back to legacy contact/phone fields when no contacts array exists", () => { + const item = mapLocationListItem({ + id: 2, + contact: "Bob", + phone: "(312) 555-0100", + }); + + expect(item.contacts).toBeUndefined(); + expect(item.contact).toBe("Bob"); + expect(item.phone).toBe("(312) 555-0100"); + }); + + it("hydrates detail contacts and mirrors the first row in contact/phone", () => { + const detail = mapLocation({ + id: 3, + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + + expect(detail.contacts).toEqual([{ id: 31, name: "Carol", phone: "555-0143" }]); + expect(detail.contact).toBe("Carol"); + expect(detail.phone).toBe("555-0143"); + }); + + it("keeps legacy detail contact/phone for zero-contact data", () => { + const detail = mapLocation({ id: 4, contact: "Legacy Person", phone: "555-0199" }); + + expect(detail.contacts).toBeUndefined(); + expect(detail.contact).toBe("Legacy Person"); + expect(detail.phone).toBe("555-0199"); + }); +}); + +describe("mapLocationToBackend contacts payload", () => { + it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { + const payload = mapLocationToBackend({ + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", + status: "Active", + contacts: [ + { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, + { name: "Bob", phone: "(421) 433-0033" }, + ], + }); + + expect(payload.contacts).toEqual([ + { id: 41, name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ]); + expect(payload.contact).toBe("Jane"); + expect(payload.phone).toBe("(421) 433-0032"); + }); + + it("keeps legacy contact/phone untouched when no contacts are provided", () => { + const payload = mapLocationToBackend({ + name: "Legacy DC", + contact: "Legacy Person", + phone: "555-0199", + }); + + expect(payload.contact).toBe("Legacy Person"); + expect(payload.phone).toBe("555-0199"); + expect(payload.contacts).toEqual([]); + }); +}); diff --git a/src/test/domain/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts new file mode 100644 index 00000000..ecf04b56 --- /dev/null +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; + +const validBase: LocationFormValues = { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", +}; + +describe("locationSchema contacts", () => { + it("accepts one fully populated contact", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + + expect(result.success).toBe(true); + }); + + it("requires at least one contact", () => { + const result = locationSchema.safeParse({ ...validBase, contacts: [] }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("At least one contact is required"); + } + }); + + it("requires a name and a phone on every row", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [ + { name: "Jane", phone: "" }, + { name: " ", phone: "(421) 433-0032" }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((issue) => issue.message); + expect(messages).toContain("Phone is required"); + expect(messages).toContain("Contact person is required"); + } + }); + + it("trims name and phone values on parse", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [{ name: " Jane ", phone: " (421) 433-0032 " }], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.contacts[0]).toEqual({ name: "Jane", phone: "(421) 433-0032" }); + } + }); +}); diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts new file mode 100644 index 00000000..ab558d03 --- /dev/null +++ b/src/test/domain/services/api/services-api.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), +})); + +import { servicesApi } from "@/domain/services/api/services-api"; + +describe("servicesApi", () => { + beforeEach(() => { + apiGet.mockReset(); + }); + + it("loads active services filtered by the selected work-order type", async () => { + apiGet.mockResolvedValue([ + { + id: 7, + name: "HVAC inspection", + trade: "HVAC", + iconKey: "hvac-inspection", + isActive: true, + supportedWorkOrderTypes: [2, "Reactive"], + }, + ]); + + const services = await servicesApi.getActiveForWorkOrderType("PM"); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: 2 }, + }); + expect(services[0]).toMatchObject({ + id: 7, + name: "HVAC inspection", + trade: "HVAC", + supportedWorkOrderTypes: ["PM", "Reactive"], + }); + }); + + it("does not request services for the unsupported Overdue type", async () => { + const services = await servicesApi.getActiveForWorkOrderType("Overdue"); + + expect(apiGet).not.toHaveBeenCalled(); + expect(services).toEqual([]); + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index b5a65e52..38a6bd20 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -28,11 +28,24 @@ describe("vendorsApi.getList", () => { CompanyId: "co-1", CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", Address: "1 Market St", City: "St. Louis", State: "MO", IsActive: false, PreferredContact: "Email", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Email: "adam@gateway.test", + Phone: "314-555-0198", + TradeSpecialties: "Plumbing", + IsActive: false, + TotalJobs: 3, + }, + ], }, ], TotalCount: 1, @@ -52,9 +65,39 @@ describe("vendorsApi.getList", () => { isActive: false, companyId: "co-1", preferredContact: "Email", + technicians: [ + expect.objectContaining({ + id: 7, + email: "adam@gateway.test", + phone: "314-555-0198", + totalJobs: 3, + }), + ], }); }); + it("synthesizes one technician for a legacy ungrouped response", async () => { + apiGet.mockResolvedValueOnce({ + Data: [ + { + Id: 9, + CompanyName: "Legacy Co", + ContactName: "Legacy Tech", + Email: "legacy@example.test", + Phone: "555-0109", + IsActive: true, + }, + ], + TotalCount: 1, + }); + + const result = await vendorsApi.getList(); + + expect(result.items[0]?.technicians).toEqual([ + expect.objectContaining({ id: 9, contactName: "Legacy Tech", phone: "555-0109" }), + ]); + }); + it("forwards all selected facets as repeated indexed query parameters", async () => { await vendorsApi.getList({ companies: ["Gateway Plumbing", "Metro HVAC"], @@ -283,3 +326,117 @@ describe("vendorsApi.getList", () => { await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict"); }); }); + +describe("vendorsApi.getDirectoryList", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ + Data: [ + { + Id: 7, + CompanyId: "co-1", + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", + Address: "1 Market St", + City: "St. Louis", + State: "MO", + IsActive: false, + PreferredContact: "Email", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Email: "adam@gateway.test", + Phone: "314-555-0198", + TradeSpecialties: "Plumbing", + IsActive: false, + TotalJobs: 3, + }, + ], + }, + ], + TotalCount: 1, + }); + }); + + it("targets the dedicated directory endpoint while getList keeps the legacy route", async () => { + await vendorsApi.getDirectoryList({ search: "gateway", isActive: false, page: 1 }); + await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 }); + + expect(API_PATHS.vendor.getDirectoryList).toBe("Vendor/GetVendorDirectoryList"); + expect(API_PATHS.vendor.getList).toBe("Vendor/GetVendorList"); + expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.getDirectoryList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.getList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + }); + + it("builds directory query parameters with the same repeated facet encoding", async () => { + await vendorsApi.getDirectoryList({ + companies: ["Gateway Plumbing", "Metro HVAC"], + trades: ["Plumbing"], + locations: ["St. Louis, MO"], + jobBuckets: ["50-99"], + }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "companies[0]": "Gateway Plumbing", + "companies[1]": "Metro HVAC", + "trades[0]": "Plumbing", + "locations[0]": "St. Louis, MO", + "jobBuckets[0]": "50-99", + }, + }); + }); + + it("maps grouped company rows with the shared grouped-row mapper", async () => { + const result = await vendorsApi.getDirectoryList({ isActive: false, page: 1 }); + + expect(result.items[0]).toMatchObject({ + companyId: "co-1", + companyName: "Gateway Plumbing", + address: "1 Market St", + city: "St. Louis", + state: "MO", + isActive: false, + technicians: [ + expect.objectContaining({ + id: 7, + contactName: "Adam Whyte", + email: "adam@gateway.test", + phone: "314-555-0198", + totalJobs: 3, + }), + ], + }); + }); +}); + +describe("vendorsApi.getDirectoryList Service Area filter", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ Data: [], TotalCount: 0 }); + }); + + it("sends areas[n] stable ids and the Unassigned sentinel", async () => { + await vendorsApi.getDirectoryList({ page: 1, areas: ["1", "__unassigned__"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "areas[0]": "1", + "areas[1]": "__unassigned__", + }, + }); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 4c9a463f..3f4beef9 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -40,6 +40,8 @@ describe("vendor roster mapper", () => { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [ { id: 7, diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx index aaec2167..b6dcae4d 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -40,6 +40,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "", notes: "Preferred vendor", + areaId: "", + areaName: "", technicians: [ { id: 11, @@ -75,6 +77,7 @@ function formValues( state: roster.state, zip: roster.zip, notes: roster.notes, + areaId: roster.areaId, technicians: [newTechnician], ...overrides, }; 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 735ef2d7..3aabb058 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 @@ -14,6 +14,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "http://maps.example.test/legacy", notes: "Legacy values remain untouched", + areaId: "", + areaName: "", technicians: [ { id: 7, @@ -37,6 +39,7 @@ const values = { state: roster.state, zip: roster.zip, notes: roster.notes, + areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ id: technician.id, contactName: technician.contactName, diff --git a/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx b/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx new file mode 100644 index 00000000..cc898d8f --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const getDirectoryList = vi.fn(); +const getList = vi.fn(); + +vi.mock("@/domain/vendors/api/vendors-api", () => ({ + vendorsApi: { + getDirectoryList: (...args: unknown[]) => getDirectoryList(...args), + getList: (...args: unknown[]) => getList(...args), + }, +})); + +import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; +import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; +import { queryKeys } from "@/infra/query-key/query-key"; + +const emptyResult = { items: [], totalCount: 0, page: 1, pageSize: 12 }; + +function createClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useVendorDirectoryList", () => { + beforeEach(() => { + getDirectoryList.mockReset(); + getList.mockReset(); + getDirectoryList.mockResolvedValue(emptyResult); + getList.mockResolvedValue(emptyResult); + }); + + it("fetches through the directory endpoint, never the legacy list", async () => { + const { result } = renderHook(() => useVendorDirectoryList({ isActive: true, page: 1 }), { + wrapper: makeWrapper(createClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getDirectoryList).toHaveBeenCalledWith({ isActive: true, page: 1 }); + expect(getList).not.toHaveBeenCalled(); + }); + + it("keeps a query key distinct from the legacy list key", () => { + const params = { isActive: true, page: 1, pageSize: 12 }; + + expect(queryKeys.vendors.directoryList(params)).not.toEqual(queryKeys.vendors.list(params)); + expect(queryKeys.vendors.directoryList(params)).toEqual( + queryKeys.vendors.directoryList(params), + ); + expect(queryKeys.vendors.directoryList(params)[1]).toBe("directoryList"); + expect(queryKeys.vendors.list(params)[1]).toBe("list"); + }); + + it("caches directory and legacy results independently for the same params", async () => { + const params = { isActive: true, page: 1 } as const; + const client = createClient(); + const directory = renderHook(() => useVendorDirectoryList({ ...params }), { + wrapper: makeWrapper(client), + }); + const legacy = renderHook(() => useVendorsList({ ...params }), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(directory.result.current.isSuccess).toBe(true)); + await waitFor(() => expect(legacy.result.current.isSuccess).toBe(true)); + + expect(getDirectoryList).toHaveBeenCalledTimes(1); + expect(getList).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 75f1e499..c4599641 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -140,6 +140,25 @@ describe("workOrdersApi.createFromBoard", () => { locationId: 9, }); }); + + it("includes the registry service id when selected in the wizard", async () => { + await workOrdersApi.createFromBoard({ + ...EMPTY_WIZARD_DRAFT, + type: "Reactive", + siteCode: "BK5", + pm: "HVAC inspection", + serviceId: 7, + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.workOrder.boardCreate, + expect.objectContaining({ + workOrderType: 6, + primaryService: "HVAC inspection", + serviceId: 7, + }), + ); + }); }); describe("workOrdersApi.update", () => { diff --git a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts index 9b74c71a..1820df72 100644 --- a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts +++ b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts @@ -4,10 +4,11 @@ import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; describe("COMPLETED DATE board column wiring", () => { - it("includes COMPLETED DATE column after COMP DOC", () => { + it("includes COMPLETED DATE column between STATUS and COMP DOC", () => { const keys = WO_TABLE_COLS.map((col) => col.key); expect(keys).toContain("completed"); - expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc")); + expect(keys.indexOf("completed")).toBe(keys.indexOf("status") + 1); + expect(keys.indexOf("completed")).toBeLessThan(keys.indexOf("doc")); }); it("expands completedDate patch to board field op", () => { diff --git a/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx new file mode 100644 index 00000000..59392029 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx @@ -0,0 +1,90 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HTTPError } from "ky"; +import type { ReactNode } from "react"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeApiRequestError } from "@/api/api-error"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +import { createTestQueryClient } from "@/test/test-utils"; + +const createFromBoard = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { createFromBoard: (...args: unknown[]) => createFromBoard(...args) }, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +/** Mirrors the ky client: HTTPError with parsed body, message normalized from it. */ +function backendError(status: number, body: Record): HTTPError { + const error = new HTTPError( + new Response(JSON.stringify(body), { status }), + new Request("https://api.example.test/workorders/board"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +async function createAndGetToast(error: unknown): Promise { + createFromBoard.mockRejectedValueOnce(error); + const { result } = renderHook(() => useCreateWorkOrderFromWizard(), { wrapper }); + act(() => { + result.current.mutate(EMPTY_WIZARD_DRAFT); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("useCreateWorkOrderFromWizard error copy", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + createFromBoard.mockReset(); + }); + + it("never surfaces raw backend validation text from a 400", async () => { + const message = await createAndGetToast( + backendError(400, { status: "Validation Error", message: "siteCode is required." }), + ); + + expect(message).toBe("Unable to create the work order. Please try again."); + }); + + it("maps a coded 422 to product copy instead of the backend field name", async () => { + const message = await createAndGetToast( + backendError(422, { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }), + ); + + expect(message).toBe("Select a vendor company before saving."); + }); + + it("falls back to generic copy for unknown 422 codes", async () => { + const message = await createAndGetToast( + backendError(422, { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus must be Incomplete, Pending, or Scheduled at create.", + }), + ); + + expect(message).toBe("Unable to create the work order. Please try again."); + }); + + it("keeps fixed permission copy for a 403", async () => { + const message = await createAndGetToast( + backendError(403, { code: "Forbidden", message: "accountId not permitted" }), + ); + + expect(message).toBe("You do not have permission to perform this action."); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index d3e1095d..97f364b5 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -224,6 +224,35 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate); expect(serverState.status).toBe(BASE_SERVER.status); }); + it("sends the follow-up status patch with the rowVersion of the just-saved row (SH-341)", async () => { + const staleTableRow = { ...BASE_ROW }; + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: () => undefined, + getRow: () => staleTableRow, + }), + { wrapper: makeWrapper() }, + ); + + let saved: Awaited> | undefined; + await act(async () => { + saved = await result.current.patchFieldAsync(42, { docStatus: "Yes" }); + }); + expect(saved?.kind).toBe("board"); + const savedRow = saved?.kind === "board" ? saved.row : undefined; + + await act(async () => { + await result.current.patchFieldAsync(42, { status: "Completed" }, savedRow); + }); + + expect(patchBoardField).toHaveBeenLastCalledWith(42, { + field: "lifecycleStatus", + value: "Completed", + workOrderVersion: "AAAAAAA=-next", + }); + }); }); describe("maybeAutoSchedule on board patch", () => { diff --git a/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts new file mode 100644 index 00000000..ab12e39e --- /dev/null +++ b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; + +function closablePmRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "24866", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + company: "Vendor Co", + vendorId: "7", + tech: "Tech One", + completedDate: "2026-09-15", + pm: "HVAC", + status: "In Progress", + docStatus: "Yes", + mediaCount: 0, + isPastDue: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + } as WorkOrderTableRow; +} + +describe("shouldCompleteAfterCompletionDoc (SH-341)", () => { + it("completes a closable PM once the generated document is persisted", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow())).toBe(true); + }); + + it("never completes on Save draft or Not required", () => { + expect(shouldCompleteAfterCompletionDoc("No", closablePmRow({ docStatus: "No" }))).toBe(false); + expect(shouldCompleteAfterCompletionDoc("NN", closablePmRow({ docStatus: "NN" }))).toBe(false); + }); + + it("does not complete when the server did not persist the document as generated", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ docStatus: "No" }))).toBe(false); + }); + + it.each([ + ["dispatcher", { dispatcherId: "", dispatcherName: "" }], + ["vendor", { company: "", vendorId: "" }], + ["completed date", { completedDate: "" }], + ["real WO number", { woNumber: "SH02137" }], + ["service", { pm: "" }], + ["pending uplift", { upliftSummary: { hasUplift: true, pendingCount: 1 } }], + ["photo on a Reactive WO", { type: "Reactive" as const, mediaCount: 0 }], + ])("keeps the SH-114 closability gate: missing %s blocks completion", (_label, overrides) => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow(overrides))).toBe(false); + }); + + it("completes a Reactive WO that has its completion photo", () => { + expect( + shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ type: "Reactive", mediaCount: 1 })), + ).toBe(true); + }); + + it("leaves Completed, Canceled, and Past Due rows unchanged", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Completed" }))).toBe( + false, + ); + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Canceled" }))).toBe( + false, + ); + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ isPastDue: true }))).toBe(false); + }); +}); diff --git a/src/test/domain/work-orders/utils/due-date-floor.test.ts b/src/test/domain/work-orders/utils/due-date-floor.test.ts new file mode 100644 index 00000000..b4c52c1b --- /dev/null +++ b/src/test/domain/work-orders/utils/due-date-floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + clearDueDateBeforeSchedule, + isDueDateBeforeFloor, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; + +describe("due date floor (SH-356)", () => { + it("uses the concrete or range-start Schedule On, else the target week's first day", () => { + expect(resolveDueDateFloor({ scheduledOn: "2026-09-10", targetWeek: "" })).toBe("2026-09-10"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "2026-09-14" })).toBe("2026-09-14"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "" })).toBe(""); + expect(resolveDueDateFloor({ scheduledOn: null, targetWeek: undefined })).toBe(""); + }); + + it("flags only a Due Date strictly before the floor", () => { + expect(isDueDateBeforeFloor("2026-09-09", "2026-09-10")).toBe(true); + expect(isDueDateBeforeFloor("2026-09-10", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("2026-09-09", "")).toBe(false); + }); + + it("clears (never bumps) a Due Date the new schedule passes", () => { + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-15")).toEqual({ dueDate: "" }); + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-12")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-20", "2026-09-15")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-12", "")).toEqual({}); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts b/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts new file mode 100644 index 00000000..c7bb498b --- /dev/null +++ b/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp"; + +describe("formatAuditTimestamp (SH-361)", () => { + it("includes the calendar date and the time for an ISO audit time", () => { + const iso = "2026-09-15T19:42:00"; + const date = new Date(iso); + const expectedDate = date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + + const formatted = formatAuditTimestamp(iso); + + expect(formatted).toBe(`${expectedDate} · 7:42 PM`); + expect(formatted).toMatch(/^\d{2}\/\d{2}\/\d{4} · \d{1,2}:\d{2} (AM|PM)$/); + }); + + it("distinguishes the same time of day on different dates", () => { + expect(formatAuditTimestamp("2026-09-14T09:48:00")).not.toBe( + formatAuditTimestamp("2026-09-15T09:48:00"), + ); + }); + + it("keeps pre-formatted or unparseable strings unchanged", () => { + expect(formatAuditTimestamp("· 7:42 PM")).toBe("· 7:42 PM"); + expect(formatAuditTimestamp("09/15/2026 · 7:42 PM")).toBe("09/15/2026 · 7:42 PM"); + }); + + it("returns an empty string when there is no time", () => { + expect(formatAuditTimestamp("")).toBe(""); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index 8c9ff415..e0a3f0b8 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -89,26 +89,67 @@ describe("formatBoardAuditAction", () => { ).toBe("WO marked as Past Due. Schedule On has passed without completion."); }); - it("formats type correction as Changed Type to (AAP, new value only)", () => { + it("formats type correction with the previous and new type (SH-363)", () => { expect( formatBoardAuditAction({ - action: "FieldPatched", + action: "FieldChanged", fieldName: "WorkOrderType", oldValue: "PM", + newValue: "Reactive", + }), + ).toBe('Changed Type from "PM" to "Reactive"'); + }); + + it("formats a first type assignment without a from clause", () => { + expect( + formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "WorkOrderType", + oldValue: "", newValue: "Emergency", }), ).toBe('Changed Type to "Emergency"'); }); - it("formats severity as Changed Severity to SEV N", () => { + it("formats severity with the previous and new SEV labels", () => { expect( formatBoardAuditAction({ - action: "FieldPatched", + action: "FieldChanged", fieldName: "Severity", oldValue: "3", newValue: "1", }), - ).toBe('Changed Severity to "SEV 1"'); + ).toBe('Changed Severity from "SEV 3" to "SEV 1"'); + }); + + it("formats severity cleared by a type correction instead of a raw FieldChanged (SH-363)", () => { + expect( + formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "Severity", + oldValue: "3", + newValue: "", + }), + ).toBe('Changed Severity from "SEV 3" to "—"'); + }); + + it("never renders a raw audit action enum for a cleared unknown field", () => { + const cleared = formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "VendorNte", + oldValue: "100", + newValue: "", + }); + const empty = formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "VendorNte", + oldValue: "", + newValue: "", + }); + + expect(cleared).toBe('Changed vendor nte from "100" to "—"'); + expect(empty).toBe("Updated vendor nte"); + expect([cleared, empty]).not.toContain("FieldChanged"); }); it("falls back to from → to for unknown fields", () => { diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts index 077eff57..1970db61 100644 --- a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, } from "@/domain/work-orders/utils/partition-slide-over-media"; @@ -76,4 +77,33 @@ describe("partition-slide-over-media", () => { }), ).toBe(true); }); + + it("renders a Completion gallery only for Reactive and Emergency work orders", () => { + expect(hasCompletionMediaGallery("Reactive")).toBe(true); + expect(hasCompletionMediaGallery("Emergency")).toBe(true); + expect(hasCompletionMediaGallery("PM")).toBe(false); + expect(hasCompletionMediaGallery("Overdue")).toBe(false); + expect(hasCompletionMediaGallery("")).toBe(false); + }); + + it("puts Extra photos and videos on Extra Docs when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const video = file({ id: "11", fileType: "video", purpose: "Extra", category: "Video" }); + + for (const item of [photo, video]) { + expect(isCompletionSurfaceMedia(item, undefined, false)).toBe(false); + expect(isExtrasSurfaceMedia(item, undefined, false)).toBe(true); + } + }); + + it("ignores stale completion ownership when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership, false)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership, false)).toBe(true); + }); }); diff --git a/src/test/domain/work-orders/utils/severity-escalation.test.ts b/src/test/domain/work-orders/utils/severity-escalation.test.ts index 812e40f9..22c2cc9a 100644 --- a/src/test/domain/work-orders/utils/severity-escalation.test.ts +++ b/src/test/domain/work-orders/utils/severity-escalation.test.ts @@ -18,6 +18,21 @@ describe("shouldAnnounceSeverityEscalation", () => { expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false); }); + it("announces when severity rises from SEV 3–5 into SEV 2", () => { + expect(shouldAnnounceSeverityEscalation(5, 2)).toBe(true); + expect(shouldAnnounceSeverityEscalation(4, 2)).toBe(true); + }); + + it("does not announce moves between SEV 3–5", () => { + expect(shouldAnnounceSeverityEscalation(5, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(4, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(3, 5)).toBe(false); + }); + + it("does not announce SEV 1 to SEV 2 (already critical)", () => { + expect(shouldAnnounceSeverityEscalation(1, 2)).toBe(false); + }); + it("does not announce SEV 2 to SEV 1 (already critical)", () => { expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false); }); diff --git a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts index 5e99b7a1..75ea23c6 100644 --- a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability"; import { + AUTOMATIC_LIFECYCLE_STATUSES, BOARD_INLINE_STATUSES, COMPLETED_DOC_BLOCK_MSG, COMPLETED_MEDIA_BLOCK_MSG, CREATION_INLINE_STATUSES, SCHEDULED_NEEDS_DATE_MSG, formatAapCompletedBlockTitle, + getInlineStatusTransitions, getStatusOptionState, hasConcreteScheduleDate, maybeAutoSchedulePatch, @@ -17,11 +19,9 @@ import { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; describe("wo-lifecycle-transitions", () => { - it("lists AAP board statuses without Canceled", () => { + it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => { expect(BOARD_INLINE_STATUSES).toEqual([ - "Incomplete", "Pending", - "Scheduled", "En Route", "On Site", "In Progress", @@ -30,6 +30,21 @@ describe("wo-lifecycle-transitions", () => { "Pending Quote", ]); expect(BOARD_INLINE_STATUSES).not.toContain("Canceled"); + for (const automatic of AUTOMATIC_LIFECYCLE_STATUSES) { + expect(BOARD_INLINE_STATUSES).not.toContain(automatic); + } + }); + + it("never offers the current status as a transition (SH-358)", () => { + expect(getInlineStatusTransitions("On Site")).toEqual([ + "Pending", + "En Route", + "In Progress", + "Completed", + "Rescheduled", + "Pending Quote", + ]); + expect(getInlineStatusTransitions("Incomplete")).toEqual(BOARD_INLINE_STATUSES); }); it("lists AAP creation statuses only", () => {