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..512d0ffc 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-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
index 15eea83d..3ea971e4 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/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts
index bf98e8c5..24023a81 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;
} = {},
@@ -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/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 de239409..7ea40ad4 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,285 +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 VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
- const trades = splitTrades(row.tradeSpecialties);
- const primaryTrade = trades[0] ?? "—";
- const omittedTrades = trades.slice(1);
- const extraTrades = omittedTrades.length;
- 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)}
-
-
-
-
-
-
-
-
- {primaryTrade === "—" ? (
- "—"
- ) : (
-
- {primaryTrade}
- {extraTrades > 0 && (
-
-
- +{extraTrades}
-
-
- )}
-
- )}
-
-
-
-
-
-
-
- {row.totalJobs ?? 0}
-
-
-
-
-
-
-
-
- {
- event.stopPropagation();
- onOpenDetail(row);
- }}
- >
-
-
-
-
- {
- event.stopPropagation();
- onOpenEdit(row);
- }}
- >
-
-
-
-
-
-
- );
-}
-
interface VendorsTableProps {
rows: VendorListItem[];
isActive: boolean;
@@ -316,25 +48,17 @@ 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) => (
+
+ ))}
@@ -400,9 +113,10 @@ export function VendorsTable({
-
- {`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..9cde2aa9 100644
--- a/src/app/(protected)/vendors/index.tsx
+++ b/src/app/(protected)/vendors/index.tsx
@@ -143,7 +143,6 @@ export default function VendorsListPage() {
return (
, ...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/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx
index dd0070b8..d389d1c4 100644
--- a/src/test/app/(protected)/vendors/vendors-list.test.tsx
+++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx
@@ -116,7 +116,12 @@ describe("VendorsListPage", () => {
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 +139,56 @@ 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,
+ },
+ ],
+ };
+ useVendorsList.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,
@@ -151,14 +205,20 @@ describe("VendorsListPage", () => {
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,
@@ -179,6 +239,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 () => {
@@ -209,9 +270,9 @@ describe("VendorsListPage", () => {
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 +292,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();
@@ -352,8 +421,8 @@ describe("VendorsListPage", () => {
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 () => {
diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts
index b5a65e52..9a014fa2 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"],