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);
+ });
+});