fix(vendors): use grouped directory endpoint

This commit is contained in:
Codex Review Integration 2026-09-14 15:24:55 -03:00
parent b1ae5085e7
commit 99253587ef
9 changed files with 389 additions and 71 deletions

View file

@ -125,6 +125,7 @@ export const API_PATHS = {
},
vendor: {
getList: "Vendor/GetVendorList",
getDirectoryList: "Vendor/GetVendorDirectoryList",
getById: "Vendor/GetById",
create: "Vendor/Create",
update: "Vendor/Update",

View file

@ -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;

View file

@ -76,6 +76,24 @@ export const vendorsApi = {
};
},
// SH-281: grouped company rows come from the dedicated directory endpoint;
// Vendor/GetVendorList stays on the legacy technician-level contract.
getDirectoryList: async (params: VendorsListParams = {}): Promise<VendorsListResult> => {
const data = await apiGet<unknown>(API_PATHS.vendor.getDirectoryList, {
searchParams: buildListParams(params),
});
const paginated = handlePaginatedResponse<unknown>(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<VendorFacets> => {
const data = await apiGet<unknown>(API_PATHS.vendor.facets, {
searchParams: buildFacetsParams(params),

View file

@ -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<VendorsListResult, Error> {
return useQuery({
queryKey: queryKeys.vendors.directoryList(params as Record<string, unknown>),
queryFn: () => vendorsApi.getDirectoryList(params),
placeholderData: keepPreviousData,
});
}

View file

@ -68,6 +68,8 @@ export const queryKeys = {
all: ["vendors"] as const,
list: (params?: Record<string, unknown>) =>
[...queryKeys.vendors.all, "list", params ?? {}] as const,
directoryList: (params?: Record<string, unknown>) =>
[...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) =>

View file

@ -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(<VendorPosListPage />, { 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();
});
});

View file

@ -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,10 +108,12 @@ 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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -169,10 +171,12 @@ describe("VendorsListPage", () => {
},
],
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([groupedVendor], 1) : result([], 0);
});
useVendorDirectoryList.mockImplementation(
(params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([groupedVendor], 1) : result([], 0);
},
);
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -198,10 +202,12 @@ 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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -228,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -260,10 +268,12 @@ 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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -319,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -346,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -370,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -394,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -414,10 +432,12 @@ 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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -434,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -475,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -516,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -546,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -577,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -602,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(<VendorsListPage />, { route: "/vendors", withAuth: false });
@ -616,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(<VendorsListPage />, { 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();
});
});

View file

@ -326,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,
}),
],
});
});
});

View file

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
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);
});
});