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 de83e430..f3a5e057 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 621f0c0d..0cd62c6d 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 0ccb0cab..80fb28ce 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 cbb183eb..6487de7d 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 1d08f61d..36602c27 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 f1bc8a52..9f45bb9b 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 eeda3e82..80bcdb86 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 f319fe67..f724fe2d 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 0035b2f1..fb262589 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 2a83659e..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 ffa12f37..fe49083c 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 b21a8ded..3fcea96e 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 907f118a..ca5acc44 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 97a77e79..c1d2bd63 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 d2e41781..8fb56c7f 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-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index d812c670..c046b9c5 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.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 13e7ffe3..35e6b135 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 b8342ed7..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], @@ -135,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" }, @@ -160,13 +170,20 @@ 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) => { @@ -244,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) @@ -307,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) => ({ @@ -410,6 +431,7 @@ test.describe("Vendor directory prototype parity", () => { "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -461,6 +483,7 @@ test.describe("Vendor directory prototype parity", () => { "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -474,7 +497,7 @@ 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(2050); + expect((await page.locator("table").boundingBox())?.width).toBe(2200); expect( (await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox()) ?.height, @@ -540,8 +563,8 @@ test.describe("Vendor directory prototype parity", () => { { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, { cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email }, { cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address }, - { cellIndex: 7, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, - { cellIndex: 8, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, + { 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) { @@ -675,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); @@ -748,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"); @@ -791,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/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/e2e/work-orders/work-orders.visual.spec.ts b/e2e/work-orders/work-orders.visual.spec.ts index 547723a6..17f8d49b 100644 --- a/e2e/work-orders/work-orders.visual.spec.ts +++ b/e2e/work-orders/work-orders.visual.spec.ts @@ -204,6 +204,12 @@ async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" }); } +async function expectWorkOrderPageReady(page: Page) { + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 90_000, + }); +} + async function expectStableScreenshot(page: Page, name: string) { await page.waitForTimeout(250); await page.evaluate( @@ -217,15 +223,18 @@ async function expectStableScreenshot(page: Page, name: string) { } test.describe("Work Orders deterministic pixel regression", () => { + test.setTimeout(120_000); + test("list", async ({ page }) => { await openWorkOrderPage(page); - await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible(); + await expectWorkOrderPageReady(page); await expect(page.getByText("WO-501").first()).toBeVisible(); await expectStableScreenshot(page, "wo-list.png"); }); test("filters", async ({ page }) => { await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); await page.getByRole("button", { name: "Advanced Filters" }).click(); await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible(); await expectStableScreenshot(page, "wo-filters.png"); @@ -233,6 +242,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("new", async ({ page }) => { await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); await page.getByRole("button", { name: "New WO" }).click(); await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible(); await expectStableScreenshot(page, "wo-new.png"); @@ -240,6 +250,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("detail", async ({ page }) => { await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); const row = page.locator("#wo-row-1"); await row.hover(); await row.getByRole("button", { name: "View details" }).click(); @@ -249,7 +260,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("empty", async ({ page }) => { await openWorkOrderPage(page, "empty"); - await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible(); + await expectWorkOrderPageReady(page); await page.getByLabel("Search work orders").fill("zz"); await expect(page.getByText("No work orders match your search")).toBeVisible(); await expectStableScreenshot(page, "wo-empty.png"); @@ -257,7 +268,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("error", async ({ page }) => { await openWorkOrderPage(page, "error"); - await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible(); + await expectWorkOrderPageReady(page); const alert = page.getByRole("main").getByRole("alert"); await expect(alert).toBeVisible(); await expect(alert).toContainText(/server error/i); @@ -267,6 +278,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("mobile", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); await expect(page.getByText("WO-501").first()).toBeVisible(); await expectStableScreenshot(page, "wo-mobile.png"); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 33db519b..9b9e3e33 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -77,6 +77,9 @@ export const API_PATHS = { deleteEmployees: "Employee/DeleteEmployees", dropdown: "Employee/Dropdown", }, + teamMember: { + getList: "User", + }, contact: { getList: "Contact/GetContactList", getContacts: "Contact/GetContacts", @@ -155,6 +158,9 @@ export const API_PATHS = { update: "DropdownOptions/Update", delete: "DropdownOptions", }, + services: { + list: "services", + }, user: { list: "User", }, diff --git a/src/app/(protected)/team-members/_components/team-members-table.tsx b/src/app/(protected)/team-members/_components/team-members-table.tsx new file mode 100644 index 00000000..125fa434 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-members-table.tsx @@ -0,0 +1,110 @@ +import { + Avatar, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; + +const HEADINGS = ["Team Member", "Role", "Service Area", "Phone", "Email", "Color", "Status"]; + +function memberStatusLabel(status: TeamMemberListItem["status"]) { + return status === "Active" ? "Active" : "Inactive"; +} + +export function TeamMembersTable({ + rows, + isLoading, + hasError, + tab, + onOpen, +}: { + rows: TeamMemberListItem[]; + isLoading: boolean; + hasError: boolean; + tab: "active" | "inactive"; + onOpen: (member: TeamMemberListItem) => void; +}) { + return ( + + + + + {HEADINGS.map((heading) => ( + + {heading} + + ))} + + Actions + + + + + {isLoading ? ( + + + + + + ) : hasError ? ( + + + Unable to load team members + + Check your connection and try again. + + + + ) : rows.length === 0 ? ( + + + No {tab} team members found + + Try a different search, or add a new team member. + + + + ) : ( + rows.map((member) => ( + onOpen(member)} + sx={{ cursor: "pointer" }} + > + {member.name || "—"} + {member.role || "—"} + {member.serviceArea || "—"} + {member.phone || "—"} + {member.email || "—"} + + + + {memberStatusLabel(member.status)} + + + + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/team-members/index.tsx b/src/app/(protected)/team-members/index.tsx new file mode 100644 index 00000000..8ce620a3 --- /dev/null +++ b/src/app/(protected)/team-members/index.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import { useDebounce } from "@/hooks/use-debounce"; +import { useTeamMembersList } from "@/domain/team-members/use-cases/use-team-members-list"; +import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; +import { + Alert, + Avatar, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + IconButton, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { TeamMembersTable } from "./_components/team-members-table"; + +type StatusTab = "active" | "inactive"; + +const STATUS_LABELS: Record = { + active: "Active", + inactive: "Inactive", +}; + +function MemberStatus({ status }: { status: TeamMemberListItem["status"] }) { + return ; +} + +function TeamMemberDrawer({ + member, + onClose, +}: { + member: TeamMemberListItem | null; + onClose: () => void; +}) { + return ( + + + + Team Member + + + + + {member !== null && ( + + + + {member.name.slice(0, 1).toUpperCase() || "?"} + + + {member.name || "—"} + {member.role || "—"} + + + + + Service Area: {member.serviceArea || "—"} + + + Phone: {member.phone || "—"} + + + Email: {member.email || "—"} + + + )} + + + ); +} + +export default function TeamMembersListPage() { + const [search, setSearch] = useState(""); + const [tab, setTab] = useState("active"); + const [selectedMember, setSelectedMember] = useState(null); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const debouncedSearch = useDebounce(search, 300).trim().toLowerCase(); + const { data, isLoading, error } = useTeamMembersList(); + const allMembers = data?.items ?? []; + + const activeCount = allMembers.filter((member) => member.status === "Active").length; + const inactiveCount = allMembers.length - activeCount; + const rows = allMembers + .filter((member) => member.status === STATUS_LABELS[tab]) + .filter((member) => !debouncedSearch || member.name.toLowerCase().includes(debouncedSearch)); + + return ( + + + + + Team Members + + + {allMembers.length} team member{allMembers.length === 1 ? "" : "s"} · {activeCount}{" "} + active + + + + setSearch(event.target.value)} + placeholder="Search team member…" + aria-label="Search team member" + sx={{ width: 224 }} + /> + + + + + setTab(value)} + aria-label="Team member status" + > + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load team members"} + + )} + + + + + + + + {rows.length} {tab} team member{rows.length === 1 ? "" : "s"} + {debouncedSearch ? " · filtered" : ""} + + + {activeCount} active · {inactiveCount} inactive + + + + setSelectedMember(null)} /> + setAddMemberOpen(false)}> + Add Member + Member details will be added here. + + + + + + ); +} 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 index e4ea83db..41e2b740 100644 --- a/src/app/(protected)/vendors/_components/vendor-table-row.tsx +++ b/src/app/(protected)/vendors/_components/vendor-table-row.tsx @@ -155,6 +155,7 @@ function CompanyCells({ row }: { row: VendorListItem }) { + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index acbf10c6..92b13609 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -21,6 +21,7 @@ const TABLE_COLUMNS = [ "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -66,7 +67,7 @@ export function VendorsTable({ size="small" stickyHeader sx={{ - minWidth: 2050, + minWidth: 2200, tableLayout: "fixed", "& .MuiTableCell-head": { backgroundColor: "var(--color-bg-muted)", @@ -94,7 +95,7 @@ export function VendorsTable({ }} > - {[190, 160, 220, 220, 140, 100, 190, 220, 170, 110, 120, 130].map((width, index) => ( + {[190, 160, 220, 220, 140, 100, 150, 190, 220, 170, 110, 120, 130].map((width, index) => ( ))} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a9968f39..9adeb936 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -13,7 +13,7 @@ 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 { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; @@ -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}` }), ); @@ -146,7 +154,15 @@ 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 ( 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 c44465b3..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,6 +6,7 @@ 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 { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media"; import { formatMediaSizeLimit, @@ -126,6 +127,7 @@ export function SlideOverCompletionTab({ docStatus={docStatus} rowVersion={row.rowVersion} completionPending={completionPending} + hasService={hasCompletionDocService(row.pm)} hasUploadHandler={Boolean(onUploadCompletionPdf)} pdfInputRef={pdfInputRef} onPdfSelected={handlePdfSelected} 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/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 39744598..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"; @@ -26,6 +32,9 @@ type EditableWONumberCellProps = { 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[], @@ -88,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); @@ -122,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 ( 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/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-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)} /> + e.stopPropagation()} + > + onPatchRow({ completedDate: date })} + /> + e.stopPropagation()}> - e.stopPropagation()} - > - onPatchRow({ completedDate: date })} - /> - ); } diff --git a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx index 4d92a8f0..039770ac 100644 --- a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx +++ b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx @@ -82,7 +82,10 @@ export function NewWoWizard({ locationDetail={wizard.locationDetail} locationDetailLoading={wizard.locationDetailLoading} locationDetailError={wizard.locationDetailError} - services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))} + services={wizard.services.map((service) => ({ + id: service.id, + value: service.name, + }))} showErrors={wizard.showErrors} svcOpen={wizard.svcOpen} onSvcOpenChange={wizard.setSvcOpen} diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts index 908c6d1a..0a5f394b 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts @@ -2,8 +2,6 @@ import { useEffect } from "react"; import { guardDialogClose } from "@/lib/dialog-guard"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; - import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; @@ -16,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft"; import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions"; import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; +import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type"; type UseNewWoWizardControllerArgs = { open: boolean; @@ -32,7 +31,7 @@ export function useNewWoWizardController({ const draftState = useNewWoWizardDraft(open); const { data: locations = [] } = useLocationSites(); - const { data: problems = [] } = useDropdownOptionsByCategory("Problem"); + const { data: registryServices = [] } = useServicesForWorkOrderType(draftState.draft.type); const { data: locationDetail, isLoading: locationDetailLoading, @@ -78,7 +77,7 @@ export function useNewWoWizardController({ ...draftState, patchDraft, locations, - problems, + services: registryServices, locationDetail, locationDetailLoading, locationDetailError, 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 bfefcf5d..baadd66e 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 @@ -32,8 +32,10 @@ export function WizardStepLocationServiceSelect({ nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; const selectSvc = (p: string) => { + const selected = services.find((service) => service.value === p); onPatch({ pm: p, + serviceId: selected?.id ?? null, extraServices: [], ...clearVendorIfPrimaryChanged(p), }); @@ -48,7 +50,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 400ad771..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 @@ -270,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/config/menu.ts b/src/config/menu.ts index 3974c0dc..c5b6fce4 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -10,6 +10,7 @@ import { Bell, BarChart3, Star, + Users, } from "lucide-react"; export type NavItemDef = { @@ -58,6 +59,10 @@ export const navGroups: NavGroup[] = [ { label: "Sites", icon: MapPin, path: "/locations" }, ], }, + { + title: "TEAM", + items: [{ label: "Team Members", icon: Users, path: "/team-members" }], + }, // { // title: "INTERNAL", // items: [{ label: "Documents", icon: FileText, path: "/documents" }], 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/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts new file mode 100644 index 00000000..9b58b486 --- /dev/null +++ b/src/domain/team-members/api/team-members-api.ts @@ -0,0 +1,13 @@ +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapTeamMemberListItems } from "@/domain/team-members/mappers/team-member-mapper"; +import type { TeamMemberListResult } from "@/domain/team-members/types/team-member"; + +export const teamMembersApi = { + getList: async (): Promise => { + const data = await apiGet(API_PATHS.teamMember.getList); + const items = handleApiResponse(data); + return { items: mapTeamMemberListItems(Array.isArray(items) ? items : []) }; + }, +}; diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts new file mode 100644 index 00000000..ada426ce --- /dev/null +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -0,0 +1,45 @@ +import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + } + return ""; +} + +function readBoolean(record: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "string") return value.toLowerCase() === "true"; + } + return false; +} + +export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { + const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const id = record.id ?? record.Id; + const status = + readString(record, "status", "Status").toLowerCase() === "inactive" || + readBoolean(record, "isDeleted", "IsDeleted") + ? "Inactive" + : "Active"; + + return { + id: typeof id === "string" || typeof id === "number" ? id : "", + name: readString(record, "name", "Name"), + role: readString(record, "role", "Role", "roleName", "RoleName"), + serviceArea: readString(record, "serviceArea", "ServiceArea", "locations", "Locations"), + phone: readString(record, "phone", "Phone"), + email: readString(record, "email", "Email"), + color: readString(record, "color", "Color"), + status, + isAccountOwner: readBoolean(record, "isAccountOwner", "IsAccountOwner"), + }; +} + +export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[] { + return rawItems.map(mapTeamMemberListItem); +} diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts new file mode 100644 index 00000000..9aafdb97 --- /dev/null +++ b/src/domain/team-members/types/team-member.ts @@ -0,0 +1,15 @@ +export interface TeamMemberListItem { + id: string | number; + name: string; + role: string; + serviceArea: string; + phone: string; + email: string; + color: string; + status: "Active" | "Inactive"; + isAccountOwner: boolean; +} + +export interface TeamMemberListResult { + items: TeamMemberListItem[]; +} diff --git a/src/domain/team-members/use-cases/use-team-members-list.ts b/src/domain/team-members/use-cases/use-team-members-list.ts new file mode 100644 index 00000000..b22f95e0 --- /dev/null +++ b/src/domain/team-members/use-cases/use-team-members-list.ts @@ -0,0 +1,11 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { TeamMemberListResult } from "@/domain/team-members/types/team-member"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useTeamMembersList(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.teamMembers.list(), + queryFn: teamMembersApi.getList, + }); +} diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index fda4215e..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 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 d7747afc..878e2336 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -30,6 +30,10 @@ 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[]; } @@ -63,6 +67,8 @@ export interface VendorsListParams { trades?: string[]; locations?: string[]; jobBuckets?: string[]; + /** Stable Area ids or the "__unassigned__" sentinel. */ + areas?: string[]; } export interface VendorsListResult { @@ -95,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[]; } @@ -147,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/work-orders/errors/board-create-errors.ts b/src/domain/work-orders/errors/board-create-errors.ts new file mode 100644 index 00000000..60437ef2 --- /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, + DUPLICATE_WO_NUMBER_MESSAGE, +} from "@/domain/work-orders/errors/board-patch-errors"; + +export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again."; + +/** 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..98481f87 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -1,3 +1,5 @@ +import { isNetworkError, isTimeoutError } from "ky"; +import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; export class BoardPatchConflictError extends Error { @@ -22,10 +24,66 @@ export class BoardPatchValidationError extends Error { } } -/** why: backend field names must never be exposed through user-facing board toasts. */ +export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving."; + +export const BOARD_PATCH_FAILED_MESSAGE = "Unable to save the work order. Please try again."; + +export const NO_PERSISTABLE_FIELDS_MESSAGE = + "These changes cannot be saved on the board. Refresh and edit a supported field."; + +export const DUPLICATE_WO_NUMBER_MESSAGE = + "This work order number is already assigned to another work order."; + +const KNOWN_VALIDATION_COPY: ReadonlyMap = new Map([ + ["DispatchRequired", DISPATCH_REQUIRED_MESSAGE], + ["NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE], + // why: editing a WO number to one that already exists returns 422 DuplicateWoNumber; + // show the same product copy as create, not the generic save-failed fallback. + ["DuplicateWoNumber", DUPLICATE_WO_NUMBER_MESSAGE], +]); + +/** + * why: board PATCH validation text ("vendorId is required…", object ids, backend codes) + * must never reach the screen. Unknown codes fall back to generic copy, not `error.message`. + */ export function boardPatchValidationMessage(error: BoardPatchValidationError): string { - if (error.code === "DispatchRequired") { - return "Select a vendor company before saving."; - } - return error.message; + return KNOWN_VALIDATION_COPY.get(error.code) ?? BOARD_PATCH_FAILED_MESSAGE; +} + +export const BOARD_PATCH_CONFLICT_MESSAGE = + "This work order was changed by someone else. Refresh to see the latest before saving again."; + +export const PARTIAL_SAVE_PREFIX = "Some changes were saved, but the rest were not."; + +/** Statuses whose mapped copy is fixed product text, never the response body. */ +const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); + +/** why: any board edit failure toast must use product copy, never backend text or field keys. */ +export function boardPatchErrorMessage(error: unknown): string { + if (error instanceof BoardPatchConflictError) { + return BOARD_PATCH_CONFLICT_MESSAGE; + } + if (error instanceof BoardPatchValidationError) { + return boardPatchValidationMessage(error); + } + if (error instanceof ApiError && FIXED_COPY_STATUSES.has(error.status)) { + return mapHttpStatusToMessage(error.status); + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return BOARD_PATCH_FAILED_MESSAGE; +} + +/** Thrown when a multi-field board edit fails after some fields already persisted. */ +export class BoardPatchPartialSaveError extends Error { + readonly partialRow: WorkOrderTableRow; + readonly failure: unknown; + + constructor(partialRow: WorkOrderTableRow, failure: unknown) { + super(`${PARTIAL_SAVE_PREFIX} ${boardPatchErrorMessage(failure)}`); + this.name = "BoardPatchPartialSaveError"; + this.partialRow = partialRow; + this.failure = failure; + } } 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 f13b387e..ca1e94ad 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -37,6 +37,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). */ @@ -82,6 +84,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 a02950d3..c0fbbe65 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 @@ -3,9 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { - boardPatchValidationMessage, + boardPatchErrorMessage, BoardPatchConflictError, + BoardPatchPartialSaveError, BoardPatchValidationError, + NO_PERSISTABLE_FIELDS_MESSAGE, } from "@/domain/work-orders/errors/board-patch-errors"; import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper"; import { @@ -58,10 +60,7 @@ async function applyBoardFieldOps( ): Promise { const ops = expandTablePatchToBoardFieldOps(patch); if (ops.length === 0) { - throw new BoardPatchValidationError( - "NoPersistableFields", - "These changes cannot be saved on the board. Refresh and edit a supported field.", - ); + throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE); } const current = getRow?.(id); @@ -77,14 +76,7 @@ async function applyBoardFieldOps( completedOps += 1; } catch (error) { if (completedOps > 0) { - const field = op.field; - const partial = new Error( - `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${ - error instanceof Error ? error.message : "Unknown error" - }`, - ); - (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest; - throw partial; + throw new BoardPatchPartialSaveError(latest, error); } throw error; } @@ -155,9 +147,8 @@ export function useWorkOrderTableMutations( invalidate(); }, onError: (error: Error, { id }) => { - const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow; - if (partialRow) { - onPatch(id, partialRow); + if (error instanceof BoardPatchPartialSaveError) { + onPatch(id, error.partialRow); toast.error(error.message); invalidate(); return; @@ -165,13 +156,10 @@ export function useWorkOrderTableMutations( if (error instanceof BoardPatchConflictError && error.currentState) { onPatch(id, error.currentState); - toast.error(error.message || "Conflict — refreshed with latest data"); - } else if (error instanceof BoardPatchValidationError) { - clearPatch(id); - toast.error(boardPatchValidationMessage(error)); + toast.error(boardPatchErrorMessage(error)); } else { clearPatch(id); - toast.error(error.message || "Failed to update work order"); + toast.error(boardPatchErrorMessage(error)); } invalidate(); }, 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 d257be12..b81da400 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -47,6 +47,10 @@ export const queryKeys = { [...queryKeys.employees.all, "list", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const, }, + teamMembers: { + all: ["teamMembers"] as const, + list: () => [...queryKeys.teamMembers.all, "list"] as const, + }, dashboard: { all: ["dashboard"] as const, stats: () => [...queryKeys.dashboard.all, "stats"] as const, @@ -56,6 +60,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) => 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 ab905729..2b98c7f0 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -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, @@ -668,4 +672,30 @@ describe("VendorsListPage", () => { 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/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/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/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/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx index 3812fb63..c46f95df 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 @@ -79,6 +79,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( expect(onPatch).toHaveBeenCalledWith({ pm: "Electrical", + serviceId: "3", extraServices: [], vendorId: "", vendorName: "", 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/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/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts new file mode 100644 index 00000000..7bfb208d --- /dev/null +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mapTeamMemberListItem } from "@/domain/team-members/mappers/team-member-mapper"; + +describe("mapTeamMemberListItem", () => { + it("maps the existing User list response and derives inactive state", () => { + expect( + mapTeamMemberListItem({ + Id: "u1", + Name: "Alex Smith", + RoleName: "Dispatcher", + Locations: "Central", + Email: "alex@example.com", + Status: "Inactive", + IsAccountOwner: true, + }), + ).toEqual({ + id: "u1", + name: "Alex Smith", + role: "Dispatcher", + serviceArea: "Central", + phone: "", + email: "alex@example.com", + color: "", + status: "Inactive", + isAccountOwner: true, + }); + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index 8b33239e..38a6bd20 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -419,3 +419,24 @@ describe("vendorsApi.getDirectoryList", () => { }); }); }); + +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/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 75f1e499..e295ed9d 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", () => { @@ -615,6 +634,28 @@ describe("workOrdersApi.patchBoardField", () => { }), ).rejects.toBeInstanceOf(BoardPatchConflictError); }); + + it("throws BoardPatchValidationError with DuplicateWoNumber code on 422", async () => { + apiPatchFn.mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ + code: "DuplicateWoNumber", + message: "WO number already exists.", + }), + }); + + await expect( + workOrdersApi.patchBoardField(10, { + field: "woNumber", + value: "12345678901", + workOrderVersion: "AAAAAAA=", + }), + ).rejects.toMatchObject({ + name: "BoardPatchValidationError", + code: "DuplicateWoNumber", + }); + }); }); describe("workOrdersApi.createComment", () => { diff --git a/src/test/domain/work-orders/errors/board-patch-errors.test.ts b/src/test/domain/work-orders/errors/board-patch-errors.test.ts new file mode 100644 index 00000000..d38217cd --- /dev/null +++ b/src/test/domain/work-orders/errors/board-patch-errors.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + BOARD_PATCH_CONFLICT_MESSAGE, + BOARD_PATCH_FAILED_MESSAGE, + BoardPatchConflictError, + BoardPatchValidationError, + DUPLICATE_WO_NUMBER_MESSAGE, + boardPatchErrorMessage, +} from "@/domain/work-orders/errors/board-patch-errors"; + +describe("boardPatchErrorMessage", () => { + it("maps a DuplicateWoNumber validation error to the duplicate-WO product copy", () => { + const error = new BoardPatchValidationError("DuplicateWoNumber", "WO number already exists."); + + expect(boardPatchErrorMessage(error)).toBe(DUPLICATE_WO_NUMBER_MESSAGE); + }); + + it("never leaks backend validation text for unknown codes", () => { + const error = new BoardPatchValidationError("SomethingElse", "vendorId is required."); + + expect(boardPatchErrorMessage(error)).toBe(BOARD_PATCH_FAILED_MESSAGE); + }); + + it("maps a conflict error to the concurrent-edit copy", () => { + const error = new BoardPatchConflictError("stale", null); + + expect(boardPatchErrorMessage(error)).toBe(BOARD_PATCH_CONFLICT_MESSAGE); + }); +}); 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-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx new file mode 100644 index 00000000..d4105ff0 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx @@ -0,0 +1,171 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BOARD_PATCH_CONFLICT_MESSAGE, + BOARD_PATCH_FAILED_MESSAGE, + DISPATCH_REQUIRED_MESSAGE, + PARTIAL_SAVE_PREFIX, +} from "@/domain/work-orders/errors/board-patch-errors"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { createTestQueryClient } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +const ROW = { id: 7, rowVersion: "v1" } as WorkOrderTableRow; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +type FakeResponse = { status: number; body: Record }; + +/** Board PATCH goes through the real ky client; only the network responses are faked. */ +function respondWith(...responses: FakeResponse[]) { + const queue = [...responses]; + globalThis.fetch = vi.fn(async () => { + const next = queue.shift() ?? responses[responses.length - 1]; + return new Response(JSON.stringify(next.body), { + status: next.status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +async function patchAndGetToast( + patch: WorkOrderTablePatch = { woNumber: "WO-100" }, + expectedCalls = 1, + onPatch: (id: string | number, patch: WorkOrderTablePatch) => void = () => undefined, +): Promise { + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => ROW, + }), + { wrapper }, + ); + act(() => { + result.current.patchField(7, patch); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(globalThis.fetch).toHaveBeenCalledTimes(expectedCalls); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("useWorkOrderTableMutations edit error copy", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("shows generic copy instead of backend text for an unknown 422 code", async () => { + respondWith({ + status: 422, + body: { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus cannot move from Completed for workOrderId 7.", + }, + }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_FAILED_MESSAGE); + expect(message).not.toMatch(/lifecycleStatus|workOrderId|InvalidLifecycleStatus/); + }); + + it("keeps specific copy for DispatchRequired", async () => { + respondWith({ + status: 422, + body: { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }, + }); + + const message = await patchAndGetToast(); + + expect(message).toBe(DISPATCH_REQUIRED_MESSAGE); + }); + + it("shows generic copy instead of backend text for a non-422 failure", async () => { + respondWith({ status: 400, body: { message: "siteCode is required for accountId 42." } }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_FAILED_MESSAGE); + }); + + it("reports a partial save in plain language without backend field keys", async () => { + const onPatch = vi.fn(); + respondWith( + { status: 200, body: { id: 7, workOrderNumber: "WO-100", rowVersion: "v2" } }, + { + status: 422, + body: { + code: "SeverityInvalid", + message: "severity 3 is not allowed for workOrderType Emergency.", + }, + }, + ); + + const message = await patchAndGetToast({ woNumber: "WO-100", severity: 3 }, 2, onPatch); + + expect(message).toBe(`${PARTIAL_SAVE_PREFIX} ${BOARD_PATCH_FAILED_MESSAGE}`); + expect(message).not.toMatch(/severity|woNumber|workOrderType|SeverityInvalid|\d\/\d/); + expect(onPatch).toHaveBeenCalledWith(7, expect.objectContaining({ rowVersion: "v2" })); + }); + + it("keeps DispatchRequired copy inside a partial save", async () => { + respondWith( + { status: 200, body: { id: 7, workOrderNumber: "WO-100", rowVersion: "v2" } }, + { + status: 422, + body: { code: "DispatchRequired", message: "vendorId is required." }, + }, + ); + + const message = await patchAndGetToast({ woNumber: "WO-100", severity: 2 }, 2); + + expect(message).toBe(`${PARTIAL_SAVE_PREFIX} ${DISPATCH_REQUIRED_MESSAGE}`); + }); + + it("shows fixed conflict copy and still applies the latest row on a 409", async () => { + const onPatch = vi.fn(); + respondWith({ + status: 409, + body: { + message: "RowVersion mismatch for workOrderId 7 (expected v1, found v3).", + currentState: { id: 7, workOrderNumber: "WO-099", rowVersion: "v3" }, + }, + }); + + const message = await patchAndGetToast({ woNumber: "WO-100" }, 1, onPatch); + + expect(message).toBe(BOARD_PATCH_CONFLICT_MESSAGE); + expect(message).not.toMatch(/RowVersion|workOrderId|v3/); + expect(onPatch).toHaveBeenLastCalledWith(7, expect.objectContaining({ rowVersion: "v3" })); + }); + + it("shows fixed conflict copy when a 409 carries no latest row", async () => { + respondWith({ status: 409, body: { message: "RowVersion mismatch for workOrderId 7." } }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_CONFLICT_MESSAGE); + }); +});