diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index c328af9a..de83e430 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index c05f2378..621f0c0d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 54c553c4..0ccb0cab 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 6c1ca3b4..cbb183eb 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index f5948c0d..1d08f61d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index a1509c39..f1bc8a52 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index c3e9be5b..eeda3e82 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 15eea83d..f319fe67 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index 901b2ab5..0035b2f1 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index a2eeb471..2a83659e 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/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 7c838f06..b8342ed7 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -97,6 +97,7 @@ async function mockVendorApi( listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean; + groupedDirectory?: boolean; legacyGateway?: boolean; longText?: boolean; } = {}, @@ -147,7 +148,7 @@ async function mockVendorApi( }, }), ); - await page.route("**/api/Vendor/GetVendorList*", async (route) => { + await page.route("**/api/Vendor/GetVendorDirectoryList*", async (route) => { const url = new URL(route.request().url()); state.listUrls.push(url.toString()); if (options.listError && url.searchParams.get("pageSize") !== "1") { @@ -168,8 +169,38 @@ async function mockVendorApi( (!company || vendor.CompanyName === company), ); const isCount = url.searchParams.get("pageSize") === "1"; + const responseRows = filtered.map((vendor) => { + if (!options.groupedDirectory) return vendor; + const technicians = [ + { + Id: vendor.Id, + ContactName: vendor.ContactName, + Email: vendor.Email, + Phone: vendor.Phone, + TradeSpecialties: vendor.TradeSpecialties, + IsActive: vendor.IsActive, + TotalJobs: vendor.TotalJobs, + }, + ]; + if (vendor.Id === 1) { + technicians.push({ + Id: 91, + ContactName: "Robin Rivers", + Email: "robin@gateway.test", + Phone: "314-555-0191", + TradeSpecialties: "Drain Cleaning", + IsActive: true, + TotalJobs: 18, + }); + } + return { + ...vendor, + TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0), + Technicians: technicians, + }; + }); await fulfillJson(route, { - Data: isCount ? filtered.slice(0, 1) : filtered, + Data: isCount ? responseRows.slice(0, 1) : responseRows, TotalCount: filtered.length, PageNumber: 1, PageSize: Number(url.searchParams.get("pageSize") ?? 12), @@ -367,6 +398,43 @@ async function mockVendorApi( } test.describe("Vendor directory prototype parity", () => { + test("renders company-owned columns and the grouped technician roster", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await mockVendorApi(page, { groupedDirectory: true }); + await page.goto("/vendors"); + + const expectedHeaders = [ + "Company", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", + "Trade", + "Technician", + "Technician Phone", + "Total Jobs", + "Status", + ]; + await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1); + for (const header of expectedHeaders) { + await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible(); + } + + const gatewayRow = page.getByRole("button", { + name: "Open vendor details for Gateway Plumbing", + }); + await expect(gatewayRow).toContainText("Adam Whyte"); + await expect(gatewayRow).toContainText("90"); + const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge"); + await expect(rosterBadge).toHaveText("+1"); + await rosterBadge.hover(); + await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers"); + await rosterBadge.focus(); + await expect(rosterBadge).toBeFocused(); + await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers"); + }); + test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { @@ -381,21 +449,24 @@ test.describe("Vendor directory prototype parity", () => { await expect(heading).toHaveCSS("font-weight", "800"); await expect(heading).toHaveCSS("line-height", "33px"); await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); - await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByText("2 active companies").first()).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", ); for (const heading of [ "Company", - "Technician", - "Location", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", "Trade", - "Phone", - "Email", + "Technician", + "Technician Phone", "Total Jobs", ]) { - await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible(); } await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); @@ -403,10 +474,11 @@ test.describe("Vendor directory prototype parity", () => { const tableContainerBox = await page.locator("table").locator("..").boundingBox(); expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); expect(tableContainerBox?.height).toBeCloseTo(738, 0); - expect((await page.locator("table").boundingBox())?.width).toBe(1550); - expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( - 47, - ); + expect((await page.locator("table").boundingBox())?.width).toBe(2050); + expect( + (await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox()) + ?.height, + ).toBe(47); expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); const tradeBadge = page.getByTestId("vendor-trade-badge").first(); @@ -419,13 +491,13 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("dialog")).toHaveCount(0); await tradeBadge.press("Enter"); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByText("2 active technicians")).toBeVisible(); + await expect(page.getByText("2 active companies").last()).toBeVisible(); await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); @@ -442,8 +514,8 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText("1 technician · filtered by")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText("1 company · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", @@ -451,7 +523,7 @@ test.describe("Vendor directory prototype parity", () => { ).toBe(true); await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No inactive technicians found")).toBeVisible(); + await expect(page.getByText("No inactive companies found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); @@ -466,10 +538,10 @@ test.describe("Vendor directory prototype parity", () => { const row = page.locator("tbody tr").first(); const fields = [ { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, - { cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, - { cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address }, - { cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone }, - { cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email }, + { cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email }, + { cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address }, + { cellIndex: 7, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, + { cellIndex: 8, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, ]; for (const expected of fields) { diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index 73ddbc30..005c9da0 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -119,7 +119,7 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = " return; } - if (url.pathname.endsWith("/api/Vendor/GetVendorList")) { + if (url.pathname.endsWith("/api/Vendor/GetVendorDirectoryList")) { const isCount = url.searchParams.get("pageSize") === "1"; if (mode === "error" && !isCount) { await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); @@ -243,8 +243,12 @@ test.describe("Vendor deterministic pixel regression", () => { test("empty", async ({ page }) => { await openVendorPage(page, "empty"); - await expect(page.getByText("No active technicians found")).toBeVisible(); + const emptyState = page.getByText("No active companies found"); + await expect(emptyState).toBeVisible(); await expectStableScreenshot(page, "vendor-empty.png"); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(emptyState).toBeInViewport(); }); test("error", async ({ page }) => { diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 37db2b85..33db519b 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -125,6 +125,7 @@ export const API_PATHS = { }, vendor: { getList: "Vendor/GetVendorList", + getDirectoryList: "Vendor/GetVendorDirectoryList", getById: "Vendor/GetById", create: "Vendor/Create", update: "Vendor/Update", diff --git a/src/app/(protected)/vendors/_components/vendor-table-row.tsx b/src/app/(protected)/vendors/_components/vendor-table-row.tsx new file mode 100644 index 00000000..e4ea83db --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-table-row.tsx @@ -0,0 +1,339 @@ +import { type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Avatar, Box, IconButton, Link, Stack, TableCell, TableRow, Tooltip } from "@mui/material"; +import { VendorStatusBadge } from "./vendor-status-badge"; +import { Text } from "@/components/ui/text"; +import type { VendorDirectoryTechnician, VendorListItem } from "@/domain/vendors/types/vendor"; + +function stopPropagation(event: MouseEvent): void { + event.stopPropagation(); +} +function stopBadgeKeyDown(event: KeyboardEvent): void { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); +} + +const truncatedTextSx = { + display: "block", + minWidth: 0, + maxWidth: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +function TruncatedText({ + value, + testId, + focusable = false, + href, + variant, +}: { + value: string; + testId: string; + focusable?: boolean; + href?: string; + variant?: "description"; +}) { + const hasValue = value !== "—"; + const content = href ? ( + + {value} + + ) : variant ? ( + + {value} + + ) : ( + + {value} + + ); + return ( + + {content} + + ); +} + +function OverflowBadge({ + count, + label, + kind, +}: { + count: number; + label: string; + kind: "trade" | "technician"; +}) { + const noun = count === 1 ? kind : `${kind}s`; + const ariaLabel = `+${count} more ${noun}${kind === "trade" ? `: ${label}` : ""}`; + return ( + + + +{count} + + + ); +} + +function CellText({ + value, + testId, + href, + focusable, +}: { + value: string; + testId: string; + href?: string; + focusable?: boolean; +}) { + return ( + + + + ); +} + +function CompanyCells({ row }: { row: VendorListItem }) { + const phone = row.companyPhone.trim(); + const email = row.email.trim(); + return ( + <> + + + + + + + + + + ); +} + +function orderedTechnicians(row: VendorListItem): VendorDirectoryTechnician[] { + const technicians = row.technicians ?? []; + const primaryIndex = + row.id == null ? -1 : technicians.findIndex((item) => String(item.id) === String(row.id)); + if (primaryIndex <= 0) return technicians; + return [technicians[primaryIndex]!, ...technicians.filter((_, index) => index !== primaryIndex)]; +} + +function primaryTechnician(row: VendorListItem, technicians: VendorDirectoryTechnician[]) { + return ( + technicians[0] ?? { + id: row.id ?? "", + contactName: row.contactName, + email: "", + phone: row.phone, + preferredContact: row.preferredContact, + tradeSpecialties: row.tradeSpecialties, + isActive: row.isActive, + totalJobs: row.totalJobs, + } + ); +} + +function TradeCell({ value }: { value: string }) { + const trades = value + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); + const omitted = trades.slice(1); + return ( + + {trades.length === 0 ? ( + "—" + ) : ( + + {trades[0]} + {omitted.length > 0 && ( + + )} + + )} + + ); +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + return `${parts[0]?.[0] ?? ""}${parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""}`.toUpperCase(); +} + +function TechnicianCells({ row }: { row: VendorListItem }) { + const technicians = orderedTechnicians(row); + const primary = primaryTechnician(row, technicians); + const omitted = technicians.slice(1); + const phone = primary.phone.trim(); + return ( + <> + + + + {getInitials(primary.contactName)} + + + {omitted.length > 0 && ( + item.contactName || "Unnamed technician").join(", ")} + kind="technician" + /> + )} + + + + + ); +} + +function RowActions({ + row, + onOpenDetail, + onOpenEdit, +}: { + row: VendorListItem; + onOpenDetail: () => void; + onOpenEdit: () => void; +}) { + const action = (label: string, icon: ReactNode, handler: () => void) => ( + + { + event.stopPropagation(); + handler(); + }} + > + {icon} + + + ); + return ( + + + {action("View", , onOpenDetail)} + {action("Edit", , onOpenEdit)} + + + ); +} + +export function VendorTableRow({ + row, + onOpenDetail, + onOpenEdit, +}: { + row: VendorListItem; + onOpenDetail: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; +}) { + const openDetail = () => onOpenDetail(row); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof Element && event.target.closest("a, button")) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + openDetail(); + }; + return ( + + + + + {row.totalJobs ?? 0} + + + + + + onOpenEdit(row)} /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index b8ad2a9c..9e61d66e 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -6,7 +6,6 @@ import { Text } from "@/components/ui/text"; import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { - globalCount: number; activeCount: number; isFetching: boolean; isLoading: boolean; @@ -18,7 +17,6 @@ interface VendorsListToolbarProps { } export function VendorsListToolbar({ - globalCount, activeCount, isFetching, isLoading, @@ -33,7 +31,7 @@ export function VendorsListToolbar({ surface className="-mx-4 -mt-4" title="Vendors / Technicians" - subtitle={`${globalCount} technicians · ${activeCount} active`} + subtitle={`${activeCount} active ${activeCount === 1 ? "company" : "companies"}`} actions={ <> - {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {resultCount} {resultCount === 1 ? "company" : "companies"} · filtered by {chips.map((chip) => ( - {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} + {totalCount} {isActive ? "active" : "inactive"} {totalCount === 1 ? "company" : "companies"} {isFiltered && " · filtered"} diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 3e851435..acbf10c6 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,13 +1,6 @@ -import { type KeyboardEvent, type MouseEvent } from "react"; import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { - Avatar, - Box, CircularProgress, - IconButton, - Link, Paper, Stack, Table, @@ -16,297 +9,24 @@ import { TableContainer, TableHead, TableRow, - Tooltip, } from "@mui/material"; -import { VendorStatusBadge } from "./vendor-status-badge"; +import { VendorTableRow } from "./vendor-table-row"; import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ "Company", - "Technician", - "Location", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", "Trade", - "Phone", - "Email", + "Technician", + "Technician Phone", "Total Jobs", ] as const; -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - -function splitTrades(value: string | undefined): string[] { - return (value ?? "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); -} - -function stopPropagation(event: MouseEvent): void { - event.stopPropagation(); -} - -function stopTradeBadgeKeyDown(event: KeyboardEvent): void { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); -} - -interface TruncatedTextProps { - value: string; - testId: string; - focusable?: boolean; - href?: string; - color?: string; - variant?: "description"; -} - -const truncatedTextSx = { - display: "block", - minWidth: 0, - maxWidth: "100%", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}; - -function TruncatedText({ - value, - testId, - focusable = false, - href, - color = "inherit", - variant, -}: TruncatedTextProps) { - const hasValue = value !== "—"; - const content = href ? ( - - {value} - - ) : variant ? ( - - {value} - - ) : ( - - {value} - - ); - - return ( - - {content} - - ); -} - -function getVendorDisplayValues(row: VendorListItem) { - const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", "); - return { - locationText: cityStateText || row.address.trim() || "—", - phoneText: row.phone.trim() || row.companyPhone.trim(), - }; -} - -interface VendorTableRowProps { - row: VendorListItem; - onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; -} - -function VendorTradeCell({ - primaryTrade, - omittedTrades, -}: { - primaryTrade: string; - omittedTrades: string[]; -}) { - const extraTrades = omittedTrades.length; - return ( - - {primaryTrade === "—" ? ( - "—" - ) : ( - - {primaryTrade} - {extraTrades > 0 && ( - - - +{extraTrades} - - - )} - - )} - - ); -} - -function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { - const trades = splitTrades(row.tradeSpecialties); - const primaryTrade = trades[0] ?? "—"; - const omittedTrades = trades.slice(1); - const { locationText, phoneText } = getVendorDisplayValues(row); - const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - onOpenDetail(row); - }; - - return ( - onOpenDetail(row)} - onKeyDown={handleKeyDown} - > - - - - - - - {getInitials(row.contactName)} - - - - - - - - - - - - - - - {row.totalJobs ?? 0} - - - - - - - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - { - event.stopPropagation(); - onOpenEdit(row); - }} - > - - - - - - - ); -} - interface VendorsTableProps { rows: VendorListItem[]; isActive: boolean; @@ -328,7 +48,6 @@ export function VendorsTable({ }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; const columnCount = TABLE_COLUMNS.length + 2; - return ( - - - - - - - - - + {[190, 160, 220, 220, 140, 100, 190, 220, 170, 110, 120, 130].map((width, index) => ( + + ))} @@ -403,18 +112,21 @@ export function VendorsTable({ {isLoading ? ( - - + + + + ) : showEmptyRow ? ( - - + + - - {`No ${isActive ? "active" : "inactive"} technicians found`} - + {`No ${isActive ? "active" : "inactive"} companies found`} {isFiltered ? "Try a different search, or add a new vendor." diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 48447ad6..a9968f39 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -15,7 +15,7 @@ import { useVendorListState } from "./_components/use-vendor-list-state"; import type { VendorFilters } from "./_components/vendor-filters"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; +import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; interface AppliedChip { category: keyof VendorFilters; @@ -125,9 +125,17 @@ export default function VendorsListPage() { const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); - const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); - const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); - const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); + const { data, isLoading, isFetching, error } = useVendorDirectoryList(filters.listParams); + const { data: activeCountData } = useVendorDirectoryList({ + isActive: true, + page: 1, + pageSize: 1, + }); + const { data: inactiveCountData } = useVendorDirectoryList({ + isActive: false, + page: 1, + pageSize: 1, + }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -143,7 +151,6 @@ export default function VendorsListPage() { return ( => { + const data = await apiGet(API_PATHS.vendor.getDirectoryList, { + searchParams: buildListParams(params), + }); + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + return { + items: mapVendorListItems(paginated.items), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + getFacets: async (params?: VendorFacetsParams): Promise => { const data = await apiGet(API_PATHS.vendor.facets, { searchParams: buildFacetsParams(params), diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 321e4542..8514dc2d 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -1,6 +1,7 @@ import type { Vendor, VendorDeactivationImpact, + VendorDirectoryTechnician, VendorFacetCompany, VendorFacetJobBucket, VendorFacetLocation, @@ -108,7 +109,7 @@ function readBool(record: Record, ...keys: string[]): boolean { export function mapVendorListItem(raw: unknown): VendorListItem { const item = asRecord(raw); - return { + const mapped = { id: readId(item), companyId: readCompanyId(item), companyName: readString(item, "companyName", "CompanyName"), @@ -126,6 +127,40 @@ export function mapVendorListItem(raw: unknown): VendorListItem { isActive: readBool(item, "isActive", "IsActive"), preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), }; + const rawTechnicians = item.technicians ?? item.Technicians; + const technicians = Array.isArray(rawTechnicians) + ? rawTechnicians.map(mapVendorDirectoryTechnician) + : mapped.id == null + ? [] + : [ + { + id: mapped.id, + contactName: mapped.contactName, + email: mapped.email, + phone: mapped.phone, + preferredContact: mapped.preferredContact, + tradeSpecialties: mapped.tradeSpecialties, + isActive: mapped.isActive, + totalJobs: mapped.totalJobs, + }, + ]; + + return { ...mapped, technicians }; +} + +function mapVendorDirectoryTechnician(raw: unknown): VendorDirectoryTechnician { + const item = asRecord(raw); + const id = readId(item); + return { + id: id ?? "", + contactName: readString(item, "contactName", "ContactName"), + email: readString(item, "email", "Email"), + phone: readString(item, "phone", "Phone"), + preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), + tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), + isActive: readBool(item, "isActive", "IsActive"), + totalJobs: readNumber(item, "totalJobs", "TotalJobs"), + }; } export function mapVendorListItems(rawItems: unknown[]): VendorListItem[] { diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index b6ee694d..d7747afc 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -2,6 +2,17 @@ export type VendorPreferredContact = "Phone" | "Email" | "Text"; export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"]; +export interface VendorDirectoryTechnician { + id: string | number; + contactName: string; + email: string; + phone: string; + preferredContact: VendorPreferredContact; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +} + export interface VendorListItem { id: string | number | null; companyId: string | number | null; @@ -19,6 +30,8 @@ export interface VendorListItem { tradeSpecialties: string; isActive: boolean; preferredContact: VendorPreferredContact; + /** Present on company-grouped directory responses; optional for legacy clients/fixtures. */ + technicians?: VendorDirectoryTechnician[]; } export interface Vendor { diff --git a/src/domain/vendors/use-cases/use-vendor-directory-list.ts b/src/domain/vendors/use-cases/use-vendor-directory-list.ts new file mode 100644 index 00000000..b610af63 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-directory-list.ts @@ -0,0 +1,14 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import type { VendorsListParams, VendorsListResult } from "@/domain/vendors/types/vendor"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useVendorDirectoryList( + params: VendorsListParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.vendors.directoryList(params as Record), + queryFn: () => vendorsApi.getDirectoryList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 36915f91..c4a9e284 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -68,6 +68,8 @@ export const queryKeys = { all: ["vendors"] as const, list: (params?: Record) => [...queryKeys.vendors.all, "list", params ?? {}] as const, + directoryList: (params?: Record) => + [...queryKeys.vendors.all, "directoryList", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const, facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const, dropdown: (trade?: string, siteZip?: string) => diff --git a/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx b/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx new file mode 100644 index 00000000..986f41ad --- /dev/null +++ b/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import VendorPosListPage from "@/app/(protected)/vendor-pos/index"; +import { renderWithProviders } from "@/test/test-utils"; + +const useVendorPosList = vi.fn(); +const useVendorsList = vi.fn(); +const useVendorDirectoryList = vi.fn(); + +vi.mock("@/domain/vendor-pos/use-cases/use-vendor-pos-list", () => ({ + useVendorPosList: (...args: unknown[]) => useVendorPosList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ + useVendorsList: (...args: unknown[]) => useVendorsList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-directory-list", () => ({ + useVendorDirectoryList: (...args: unknown[]) => useVendorDirectoryList(...args), +})); + +describe("VendorPosListPage vendor source", () => { + it("keeps the legacy technician-level vendors list with pageSize 500", () => { + useVendorPosList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }); + useVendorsList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 500 }, + isLoading: false, + isFetching: false, + error: null, + }); + useVendorDirectoryList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 500 }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderWithProviders(, { route: "/vendor-pos", withAuth: false }); + + expect(useVendorsList).toHaveBeenCalledWith({ page: 1, pageSize: 500 }); + expect(useVendorsList).toHaveBeenCalledTimes(1); + expect(useVendorDirectoryList).not.toHaveBeenCalled(); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index dd0070b8..ab905729 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -5,12 +5,12 @@ import VendorsListPage from "@/app/(protected)/vendors/index"; import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; -const useVendorsList = vi.fn(); +const useVendorDirectoryList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); -vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ - useVendorsList: (...args: unknown[]) => useVendorsList(...args), +vi.mock("@/domain/vendors/use-cases/use-vendor-directory-list", () => ({ + useVendorDirectoryList: (...args: unknown[]) => useVendorDirectoryList(...args), })); vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ @@ -108,15 +108,22 @@ function setupDefaults() { describe("VendorsListPage", () => { it("renders prototype-aligned status and two row actions in both tabs", async () => { setupDefaults(); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); - return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); + return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); - expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Company Phone" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Company Email" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Street Address" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Technician Phone" })).toBeInTheDocument(); + expect(screen.getByText("St. Louis")).toBeInTheDocument(); + expect(screen.getByText("MO")).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); expect( @@ -134,7 +141,58 @@ describe("VendorsListPage", () => { expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); - it("prefers trimmed city and state over address and technician phone over company phone", () => { + it("renders one company row with the primary technician and a keyboard-accessible roster badge", async () => { + setupDefaults(); + const groupedVendor: VendorListItem = { + ...activeVendor, + id: 22, + contactName: "Promoted Technician", + totalJobs: 9, + technicians: [ + { + id: 11, + contactName: "Older Technician", + email: "older@gateway.test", + phone: "314-555-0111", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 4, + }, + { + id: 22, + contactName: "Promoted Technician", + email: "promoted@gateway.test", + phone: "314-555-0222", + preferredContact: "Email", + tradeSpecialties: "HVAC", + isActive: true, + totalJobs: 5, + }, + ], + }; + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([groupedVendor], 1) : result([], 0); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getAllByText("Gateway Plumbing")).toHaveLength(1); + expect(screen.getByText("Promoted Technician")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "314-555-0222" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "9" })).toBeInTheDocument(); + + const rosterBadge = screen.getByTestId("vendor-technician-badge"); + expect(rosterBadge).toHaveTextContent("+1"); + expect(rosterBadge).toHaveAttribute("tabindex", "0"); + await userEvent.hover(rosterBadge); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Older Technician"); + }); + + it("renders company location and contact fields in separate columns", () => { setupDefaults(); const vendorWithAllFields: VendorListItem = { ...activeVendor, @@ -144,21 +202,29 @@ describe("VendorsListPage", () => { state: " MO ", phone: " (314) 555-0198 ", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([vendorWithAllFields], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([vendorWithAllFields], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.queryByText(/1 Market St/)).not.toBeInTheDocument(); - const phoneLink = screen.getByRole("link", { name: "(314) 555-0198" }); - expect(phoneLink).toHaveAttribute("href", "tel:(314) 555-0198"); - expect(screen.queryByText("(314) 555-0100")).not.toBeInTheDocument(); + expect(screen.getByText("1 Market St")).toBeInTheDocument(); + expect(screen.getByText("St. Louis")).toBeInTheDocument(); + expect(screen.getByText("MO")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "(314) 555-0198" })).toHaveAttribute( + "href", + "tel:(314) 555-0198", + ); + expect(screen.getByRole("link", { name: "(314) 555-0100" })).toHaveAttribute( + "href", + "tel:(314) 555-0100", + ); }); - it("falls back to the trimmed address and company phone when technician fields are absent", () => { + it("keeps company and technician phone ownership separate when technician phone is absent", () => { setupDefaults(); const fallbackVendor: VendorListItem = { ...activeVendor, @@ -168,10 +234,12 @@ describe("VendorsListPage", () => { state: "", phone: " ", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([fallbackVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -179,6 +247,7 @@ describe("VendorsListPage", () => { const companyPhoneLink = screen.getByRole("link", { name: "(314) 555-0100" }); expect(companyPhoneLink).toHaveAttribute("href", "tel:(314) 555-0100"); expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument(); + expect(screen.getByTestId("vendor-text-technician-phone")).toHaveTextContent("—"); }); it("truncates every textual table field and reveals each full value", async () => { @@ -199,19 +268,21 @@ describe("VendorsListPage", () => { city: "", state: "", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([fallbackVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); const fields = [ { testId: "vendor-text-company", value: longCompany }, { testId: "vendor-text-technician", value: longTechnician }, - { testId: "vendor-text-location", value: longAddress }, - { testId: "vendor-text-phone", value: longPhone }, - { testId: "vendor-text-email", value: longEmail }, + { testId: "vendor-text-street-address", value: longAddress }, + { testId: "vendor-text-technician-phone", value: longPhone }, + { testId: "vendor-text-company-email", value: longEmail }, ]; for (const field of fields) { @@ -231,10 +302,18 @@ describe("VendorsListPage", () => { await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); } - expect(screen.getByTestId("vendor-text-phone")).toHaveAttribute("href", `tel:${longPhone}`); - expect(screen.getByTestId("vendor-text-email")).toHaveAttribute("href", `mailto:${longEmail}`); + expect(screen.getByTestId("vendor-text-technician-phone")).toHaveAttribute( + "href", + `tel:${longPhone}`, + ); + expect(screen.getByTestId("vendor-text-company-email")).toHaveAttribute( + "href", + `mailto:${longEmail}`, + ); + fireEvent.keyDown(screen.getByTestId("vendor-text-company-email"), { key: "Enter" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - const locationText = screen.getByTestId("vendor-text-location"); + const locationText = screen.getByTestId("vendor-text-street-address"); fireEvent.keyDown(document, { key: "Tab" }); locationText.focus(); expect(locationText).toHaveFocus(); @@ -250,10 +329,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -277,10 +358,12 @@ describe("VendorsListPage", () => { it("does not show the +N badge when the vendor has a single trade", () => { setupDefaults(); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -301,10 +384,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -325,10 +410,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -345,15 +432,17 @@ describe("VendorsListPage", () => { companyName: `Vendor ${index + 1}`, })); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); - return result(pageVendors, 25); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); + return result(pageVendors, 25); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/25 active companies/)).not.toHaveLength(0); + expect(screen.queryByText(/3 active companies/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { @@ -365,10 +454,12 @@ describe("VendorsListPage", () => { error: null, refetch: vi.fn(), }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -406,10 +497,12 @@ describe("VendorsListPage", () => { isLoading: false, error: null, }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -447,10 +540,12 @@ describe("VendorsListPage", () => { isLoading: false, error: null, }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -477,10 +572,12 @@ describe("VendorsListPage", () => { isLoading: false, error: null, }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -508,10 +605,12 @@ describe("VendorsListPage", () => { error: null, refetch: vi.fn(), }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -533,10 +632,12 @@ describe("VendorsListPage", () => { error: null, refetch: vi.fn(), }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -547,4 +648,24 @@ describe("VendorsListPage", () => { "Gateway Plumbing", ); }); + + it("loads rows and status counts through the directory hook", async () => { + setupDefaults(); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 3 : 2); + return result([activeVendor], 3); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(useVendorDirectoryList).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, pageSize: 48 }), + ); + expect(useVendorDirectoryList).toHaveBeenCalledWith({ isActive: true, page: 1, pageSize: 1 }); + expect(useVendorDirectoryList).toHaveBeenCalledWith({ isActive: false, page: 1, pageSize: 1 }); + expect(screen.getByRole("tab", { name: "Active (3)" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Inactive (2)" })).toBeInTheDocument(); + }); }); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index b5a65e52..8b33239e 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -28,11 +28,24 @@ describe("vendorsApi.getList", () => { CompanyId: "co-1", CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", Address: "1 Market St", City: "St. Louis", State: "MO", IsActive: false, PreferredContact: "Email", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Email: "adam@gateway.test", + Phone: "314-555-0198", + TradeSpecialties: "Plumbing", + IsActive: false, + TotalJobs: 3, + }, + ], }, ], TotalCount: 1, @@ -52,9 +65,39 @@ describe("vendorsApi.getList", () => { isActive: false, companyId: "co-1", preferredContact: "Email", + technicians: [ + expect.objectContaining({ + id: 7, + email: "adam@gateway.test", + phone: "314-555-0198", + totalJobs: 3, + }), + ], }); }); + it("synthesizes one technician for a legacy ungrouped response", async () => { + apiGet.mockResolvedValueOnce({ + Data: [ + { + Id: 9, + CompanyName: "Legacy Co", + ContactName: "Legacy Tech", + Email: "legacy@example.test", + Phone: "555-0109", + IsActive: true, + }, + ], + TotalCount: 1, + }); + + const result = await vendorsApi.getList(); + + expect(result.items[0]?.technicians).toEqual([ + expect.objectContaining({ id: 9, contactName: "Legacy Tech", phone: "555-0109" }), + ]); + }); + it("forwards all selected facets as repeated indexed query parameters", async () => { await vendorsApi.getList({ companies: ["Gateway Plumbing", "Metro HVAC"], @@ -283,3 +326,96 @@ describe("vendorsApi.getList", () => { await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict"); }); }); + +describe("vendorsApi.getDirectoryList", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ + Data: [ + { + Id: 7, + CompanyId: "co-1", + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", + Address: "1 Market St", + City: "St. Louis", + State: "MO", + IsActive: false, + PreferredContact: "Email", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Email: "adam@gateway.test", + Phone: "314-555-0198", + TradeSpecialties: "Plumbing", + IsActive: false, + TotalJobs: 3, + }, + ], + }, + ], + TotalCount: 1, + }); + }); + + it("targets the dedicated directory endpoint while getList keeps the legacy route", async () => { + await vendorsApi.getDirectoryList({ search: "gateway", isActive: false, page: 1 }); + await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 }); + + expect(API_PATHS.vendor.getDirectoryList).toBe("Vendor/GetVendorDirectoryList"); + expect(API_PATHS.vendor.getList).toBe("Vendor/GetVendorList"); + expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.getDirectoryList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.getList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + }); + + it("builds directory query parameters with the same repeated facet encoding", async () => { + await vendorsApi.getDirectoryList({ + companies: ["Gateway Plumbing", "Metro HVAC"], + trades: ["Plumbing"], + locations: ["St. Louis, MO"], + jobBuckets: ["50-99"], + }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "companies[0]": "Gateway Plumbing", + "companies[1]": "Metro HVAC", + "trades[0]": "Plumbing", + "locations[0]": "St. Louis, MO", + "jobBuckets[0]": "50-99", + }, + }); + }); + + it("maps grouped company rows with the shared grouped-row mapper", async () => { + const result = await vendorsApi.getDirectoryList({ isActive: false, page: 1 }); + + expect(result.items[0]).toMatchObject({ + companyId: "co-1", + companyName: "Gateway Plumbing", + address: "1 Market St", + city: "St. Louis", + state: "MO", + isActive: false, + technicians: [ + expect.objectContaining({ + id: 7, + contactName: "Adam Whyte", + email: "adam@gateway.test", + phone: "314-555-0198", + totalJobs: 3, + }), + ], + }); + }); +}); diff --git a/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx b/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx new file mode 100644 index 00000000..cc898d8f --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const getDirectoryList = vi.fn(); +const getList = vi.fn(); + +vi.mock("@/domain/vendors/api/vendors-api", () => ({ + vendorsApi: { + getDirectoryList: (...args: unknown[]) => getDirectoryList(...args), + getList: (...args: unknown[]) => getList(...args), + }, +})); + +import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; +import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; +import { queryKeys } from "@/infra/query-key/query-key"; + +const emptyResult = { items: [], totalCount: 0, page: 1, pageSize: 12 }; + +function createClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useVendorDirectoryList", () => { + beforeEach(() => { + getDirectoryList.mockReset(); + getList.mockReset(); + getDirectoryList.mockResolvedValue(emptyResult); + getList.mockResolvedValue(emptyResult); + }); + + it("fetches through the directory endpoint, never the legacy list", async () => { + const { result } = renderHook(() => useVendorDirectoryList({ isActive: true, page: 1 }), { + wrapper: makeWrapper(createClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getDirectoryList).toHaveBeenCalledWith({ isActive: true, page: 1 }); + expect(getList).not.toHaveBeenCalled(); + }); + + it("keeps a query key distinct from the legacy list key", () => { + const params = { isActive: true, page: 1, pageSize: 12 }; + + expect(queryKeys.vendors.directoryList(params)).not.toEqual(queryKeys.vendors.list(params)); + expect(queryKeys.vendors.directoryList(params)).toEqual( + queryKeys.vendors.directoryList(params), + ); + expect(queryKeys.vendors.directoryList(params)[1]).toBe("directoryList"); + expect(queryKeys.vendors.list(params)[1]).toBe("list"); + }); + + it("caches directory and legacy results independently for the same params", async () => { + const params = { isActive: true, page: 1 } as const; + const client = createClient(); + const directory = renderHook(() => useVendorDirectoryList({ ...params }), { + wrapper: makeWrapper(client), + }); + const legacy = renderHook(() => useVendorsList({ ...params }), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(directory.result.current.isSuccess).toBe(true)); + await waitFor(() => expect(legacy.result.current.isSuccess).toBe(true)); + + expect(getDirectoryList).toHaveBeenCalledTimes(1); + expect(getList).toHaveBeenCalledTimes(1); + }); +});