From 2bff7176bec4802bc0d1331f4952cffc00cd6504 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:55:40 -0300 Subject: [PATCH] feat(vendors): add Service Area assignment, filter and column Replace the free-text vendor State with a closed 50-state list, add an admin-only Service Area select below Notes, a Service Area advanced filter (stable ids plus Unassigned) and a Service Area directory column. --- e2e/vendors/vendors.spec.ts | 88 +++++++- .../_components/use-vendor-list-state.ts | 2 + .../_components/use-vendor-roster-form.ts | 28 ++- .../use-vendor-roster-selection.ts | 1 + .../_components/vendor-create-modal.tsx | 1 + .../_components/vendor-detail-drawer.tsx | 2 + .../_components/vendor-filter-options.tsx | 46 +++- .../_components/vendor-filters-drawer.tsx | 6 +- .../vendors/_components/vendor-filters.ts | 15 ++ .../_components/vendor-roster-form-fields.tsx | 29 ++- .../_components/vendor-service-area-field.tsx | 58 ++++++ .../_components/vendor-state-select-field.tsx | 57 +++++ .../vendors/_components/vendor-table-row.tsx | 1 + .../vendors/_components/vendors-table.tsx | 5 +- src/app/(protected)/vendors/index.tsx | 20 +- src/domain/vendors/api/vendors-api.ts | 1 + src/domain/vendors/mappers/vendor-mapper.ts | 15 ++ .../vendors/mappers/vendor-roster-mapper.ts | 18 +- .../vendors/schemas/vendor-roster-schema.ts | 3 + src/domain/vendors/types/vendor.ts | 19 ++ .../use-save-vendor-company-roster.ts | 1 + .../vendors/use-vendor-roster-form.test.tsx | 4 + .../vendors/vendor-create-modal.test.tsx | 12 +- .../vendors/vendor-detail-drawer.test.tsx | 4 + .../vendor-roster-address-fields.test.tsx | 18 +- .../vendors/vendor-service-area.test.tsx | 197 ++++++++++++++++++ .../vendors/vendor-state-select.ts | 15 ++ .../(protected)/vendors/vendors-list.test.tsx | 30 +++ .../domain/vendors/api/vendors-api.test.ts | 21 ++ .../mappers/vendor-roster-mapper.test.ts | 2 + ...ve-vendor-company-roster-additive.test.tsx | 3 + .../use-save-vendor-company-roster.test.ts | 3 + 32 files changed, 684 insertions(+), 41 deletions(-) create mode 100644 src/app/(protected)/vendors/_components/vendor-service-area-field.tsx create mode 100644 src/app/(protected)/vendors/_components/vendor-state-select-field.tsx create mode 100644 src/test/app/(protected)/vendors/vendor-service-area.test.tsx create mode 100644 src/test/app/(protected)/vendors/vendor-state-select.ts diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index b8342ed7..27469fe4 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -29,6 +29,8 @@ const activeVendors = [ TotalJobs: 72, IsActive: true, PreferredContact: "Email", + AreaId: 1 as number | null, + AreaName: "East", }, { Id: 2, @@ -47,9 +49,16 @@ const activeVendors = [ Notes: "", TotalJobs: 18, IsActive: true, + AreaId: null as number | null, + AreaName: "", }, ]; +const SERVICE_AREAS = [ + { id: 2, name: "Central" }, + { id: 1, name: "East" }, +]; + const inactiveVendors = [ { ...activeVendors[0], @@ -135,6 +144,7 @@ async function mockVendorApi( googleMapsUrl: vendor.GoogleMapsUrl, })), trades: ["Plumbing", "Backflow Preventers", "HVAC"], + areas: SERVICE_AREAS, locations: [ { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, { city: "Clayton", state: "MO", label: "Clayton, MO" }, @@ -160,13 +170,20 @@ async function mockVendorApi( const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive); const search = (url.searchParams.get("search") ?? "").toLowerCase(); const company = url.searchParams.get("companies[0]"); + const areas = [...url.searchParams.entries()] + .filter(([key]) => key.startsWith("areas[")) + .map(([, value]) => value); const filtered = source.filter( (vendor) => (!search || `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}` .toLowerCase() .includes(search)) && - (!company || vendor.CompanyName === company), + (!company || vendor.CompanyName === company) && + (areas.length === 0 || + areas.some((area) => + area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area, + )), ); const isCount = url.searchParams.get("pageSize") === "1"; const responseRows = filtered.map((vendor) => { @@ -244,6 +261,8 @@ async function mockVendorApi( zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, + areaId: anchor.AreaId, + areaName: anchor.AreaName, technicians: [ ...vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) @@ -307,6 +326,8 @@ async function mockVendorApi( zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, + areaId: anchor.AreaId, + areaName: anchor.AreaName, technicians: vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) .map((vendor) => ({ @@ -410,6 +431,7 @@ test.describe("Vendor directory prototype parity", () => { "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -461,6 +483,7 @@ test.describe("Vendor directory prototype parity", () => { "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -474,7 +497,7 @@ test.describe("Vendor directory prototype parity", () => { const tableContainerBox = await page.locator("table").locator("..").boundingBox(); expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); expect(tableContainerBox?.height).toBeCloseTo(738, 0); - expect((await page.locator("table").boundingBox())?.width).toBe(2050); + expect((await page.locator("table").boundingBox())?.width).toBe(2200); expect( (await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox()) ?.height, @@ -540,8 +563,8 @@ test.describe("Vendor directory prototype parity", () => { { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, { cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email }, { cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address }, - { cellIndex: 7, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, - { cellIndex: 8, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, + { cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, + { cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, ]; for (const expected of fields) { @@ -675,7 +698,8 @@ test.describe("Vendor directory prototype parity", () => { "1 Market St", ); await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis"); - await expect(page.getByLabel("State (required)")).toHaveValue("MO"); + await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)"); + await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East"); await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0); await expect(page.getByLabel("Address (optional)")).toHaveCount(0); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); @@ -748,6 +772,55 @@ test.describe("Vendor directory prototype parity", () => { await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); + test("filters by Service Area and lets an admin assign one", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + const gatewayRow = page.getByRole("button", { + name: "Open vendor details for Gateway Plumbing", + }); + const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" }); + await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East"); + await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned"); + + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await page.getByRole("button", { name: "Service Area" }).click(); + await page.getByPlaceholder("All areas").click(); + await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]); + await page.getByRole("option", { name: "East" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); + + await expect( + page.getByRole("button", { name: "Remove filter Service Area: East" }), + ).toBeVisible(); + await expect(gatewayRow).toBeVisible(); + await expect(metroRow).toHaveCount(0); + expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe( + true, + ); + + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await page.getByRole("button", { name: "Service Area" }).click(); + await page.getByRole("button", { name: "Clear all" }).last().click(); + await page.getByPlaceholder("All areas").click(); + await page.getByRole("option", { name: "Unassigned" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); + await expect(page.getByText("Service Area: Unassigned")).toBeVisible(); + await expect(metroRow).toBeVisible(); + await expect(gatewayRow).toHaveCount(0); + + await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click(); + const detailDrawer = page.locator(".MuiDrawer-paper").last(); + const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" }); + await expect(areaField).toBeEnabled(); + await areaField.click(); + await page.getByRole("option", { name: "Central" }).click(); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + await expect.poll(() => state.updatedBody?.areaId).toBe(2); + expect(state.updatedBody?.state).toBe("MO"); + }); + test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); @@ -791,11 +864,14 @@ test.describe("Vendor directory prototype parity", () => { // leave those as the only things it is exercising. await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St"); await page.getByLabel("City (required)").fill("St. Louis"); - await page.getByLabel("State (required)").fill("MO"); + await page.getByLabel("State (required)").fill("Missouri"); + await page.getByRole("option", { name: "Missouri (MO)" }).click(); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); + expect(state.createdBody?.state).toBe("MO"); + expect(state.createdBody?.areaId).toBeNull(); }); test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => { diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index fdc90685..0b56ddf3 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -16,6 +16,7 @@ export interface VendorListState { isActive: boolean; companies: string[]; trades: string[]; + areas: string[]; locations: string[]; jobBuckets: string[]; }; @@ -45,6 +46,7 @@ export function useVendorListState(): VendorListState { isActive, companies: appliedFilters.companies, trades: appliedFilters.trades, + areas: appliedFilters.areas, locations: appliedFilters.locations, jobBuckets: appliedFilters.jobBuckets, }), diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index aaf1b606..0ebb20f5 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useContext, useMemo, useState } from "react"; import { toast } from "react-toastify"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { @@ -15,6 +15,8 @@ import { type EditedCompanyFields, } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; +import { AuthContext } from "@/providers/auth-context"; +import { isAdminUser } from "@/lib/auth/user-utils"; const VENDOR_ROSTER_NO_CHANGES_MESSAGE = "No changes to save. Enter technician details or update a company field."; @@ -28,6 +30,7 @@ const EDITABLE_COMPANY_FIELDS = [ "state", "zip", "notes", + "areaId", ] as const; /** @@ -48,10 +51,21 @@ import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; import type { VendorCompanyRoster, + VendorFacetArea, VendorFacetCompany, VendorRosterConflict, } from "@/domain/vendors/types/vendor"; +export interface VendorRosterAreaFieldProps { + areaOptions: VendorFacetArea[]; + currentArea: VendorFacetArea | null; + canAssignArea: boolean; +} + +function toCurrentArea(roster: VendorCompanyRoster | null | undefined): VendorFacetArea | null { + return roster && roster.areaId !== "" ? { id: roster.areaId, name: roster.areaName } : null; +} + function withoutBlankNewTechnicians( values: VendorCompanyRosterFormValues, ): VendorCompanyRosterFormValues { @@ -96,6 +110,7 @@ export interface VendorRosterForm { companies: VendorFacetCompany[]; trades: string[]; tradesLoading: boolean; + areaField: VendorRosterAreaFieldProps; selectedCompanyId: string | number | null; selectCompany: (company: VendorFacetCompany | null) => Promise; clearSelectedCompany: (nextName?: string) => void; @@ -116,6 +131,8 @@ export function useVendorRosterForm({ ); const { data: facets, isLoading: facetsLoading } = useVendorFacets(); const save = useSaveVendorCompanyRoster(); + // SH-278: Area assignment is Admin-only; the API enforces it and the field mirrors it. + const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles); const [conflict, setConflict] = useState(null); const routeRoster = mode === "update" ? query.data : undefined; @@ -151,6 +168,14 @@ export function useVendorRosterForm({ clearConflict, }); const { resetSelection } = selection; + const areaField = useMemo( + () => ({ + areaOptions: facets?.areas ?? [], + currentArea: toCurrentArea(routeRoster ?? selection.selectedRoster), + canAssignArea, + }), + [canAssignArea, facets, routeRoster, selection.selectedRoster], + ); const isValid = useMemo(() => { if (vendorCompanyRosterSchema.safeParse(watched).success) return true; @@ -238,6 +263,7 @@ export function useVendorRosterForm({ companies, trades, tradesLoading: facetsLoading, + areaField, selectedCompanyId: selection.selectedCompanyId, selectCompany: selection.selectCompany, clearSelectedCompany: selection.clearSelectedCompany, diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index a472546e..f0893e32 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -43,6 +43,7 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo // The deprecated stored Maps URL is intentionally not seeded: the form has // no input for it, and carrying it would re-emit a legacy value on write. notes: roster.notes, + areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ id: technician.id, contactName: technician.contactName, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 55e3506d..cf8dcb8e 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -77,6 +77,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { selectedCompanyId={form.selectedCompanyId} onSelectCompany={form.selectCompany} onClearSelectedCompany={form.clearSelectedCompany} + {...form.areaField} /> diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 072bddb7..2a69b204 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -155,6 +155,7 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) { + ); @@ -336,6 +337,7 @@ function DrawerEditor({ tradeOptions={form.trades} tradeOptionsLoading={form.tradesLoading} showTechnicianStatus={false} + {...form.areaField} /> {selectedIndex >= 0 && ( <> diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx index c9be7d45..aac0b212 100644 --- a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -3,6 +3,7 @@ import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; import { Accordion, @@ -17,13 +18,14 @@ import { TextField, Typography, } from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, +import { + type VendorFacetArea, + type VendorFacets, + type VendorFacetCompany, + type VendorFacetJobBucket, + type VendorFacetLocation, } from "@/domain/vendors/types/vendor"; -import type { VendorFilters } from "./vendor-filters"; +import { serviceAreaFilterOptions, type VendorFilters } from "./vendor-filters"; type DraftSetter = Dispatch>; @@ -156,6 +158,33 @@ interface FilterProps { setDraft: DraftSetter; } +function ServiceAreaFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={options.filter((option) => draft.areas.includes(option.id))} + onChange={(_event, value) => + setDraft((current) => ({ ...current, areas: value.map((entry) => entry.id) })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + function LocationFilter({ options, draft, setDraft }: FilterProps) { return ( }> @@ -245,6 +274,11 @@ export function VendorFilterOptions({ <> + diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index b18c568c..1aae6720 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -31,7 +31,11 @@ export function VendorFiltersDrawer({ }, [open, appliedFilters]); const activeCount = - draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + draft.companies.length + + draft.trades.length + + draft.areas.length + + draft.locations.length + + draft.jobBuckets.length; const handleClear = () => { setDraft(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/vendor-filters.ts b/src/app/(protected)/vendors/_components/vendor-filters.ts index 3c236daa..61ffc897 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters.ts +++ b/src/app/(protected)/vendors/_components/vendor-filters.ts @@ -1,6 +1,10 @@ +import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor"; + export interface VendorFilters { companies: string[]; trades: string[]; + /** SH-278 stable Area ids or VENDOR_AREA_UNASSIGNED; OR within the facet. */ + areas: string[]; locations: string[]; jobBuckets: string[]; } @@ -8,6 +12,17 @@ export interface VendorFilters { export const emptyVendorFilters: VendorFilters = { companies: [], trades: [], + areas: [], locations: [], jobBuckets: [], }; + +const UNASSIGNED_AREA_OPTION: VendorFacetArea = { id: VENDOR_AREA_UNASSIGNED, name: "Unassigned" }; + +/** SH-278: Unassigned first, then the active Area catalogue A–Z (case-insensitive). */ +export function serviceAreaFilterOptions(areas: VendorFacetArea[]): VendorFacetArea[] { + const sorted = [...areas].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + return [UNASSIGNED_AREA_OPTION, ...sorted]; +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index cf23d357..d5094cf0 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -30,8 +30,11 @@ import { type RosterTechnicianValues, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { VendorFacetArea, VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import { normalizeStateCode } from "@/data/areas"; import { VendorCompanyContactFields } from "./vendor-company-contact-fields"; +import { VendorServiceAreaField } from "./vendor-service-area-field"; +import { VendorStateSelectField } from "./vendor-state-select-field"; interface VendorRosterFormFieldsProps { control: Control; @@ -43,6 +46,9 @@ interface VendorRosterFormFieldsProps { onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; showTechnicianStatus?: boolean; + areaOptions?: VendorFacetArea[]; + currentArea?: VendorFacetArea | null; + canAssignArea?: boolean; } function CompanyNameField({ @@ -136,6 +142,9 @@ function CompanyFields({ selectedCompanyId, onSelectCompany, onClearSelectedCompany, + areaOptions = [], + currentArea = null, + canAssignArea = false, }: VendorRosterFormFieldsProps) { const notes = useWatch({ control, name: "notes" }) ?? ""; const { field: streetField } = useController({ control, name: "address" }); @@ -163,7 +172,7 @@ function CompanyFields({ // revalidates — a plain assignment would be discarded on save. streetField.onChange(parts.street); cityField.onChange(parts.city); - stateField.onChange(parts.state); + stateField.onChange(normalizeStateCode(parts.state) ?? parts.state); }} label="Street Address (required)" required @@ -183,15 +192,13 @@ function CompanyFields({ fullWidth slotProps={{ htmlInput: { required: true } }} /> - )} /> + ); diff --git a/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx b/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx new file mode 100644 index 00000000..61c14fb5 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx @@ -0,0 +1,58 @@ +import { Controller, type Control } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { VendorFacetArea } from "@/domain/vendors/types/vendor"; + +interface VendorServiceAreaFieldProps { + control: Control; + areaOptions: VendorFacetArea[]; + currentArea?: VendorFacetArea | null; + canAssign: boolean; +} + +export const SERVICE_AREA_ADMIN_ONLY_MESSAGE = "Only administrators can assign a Service Area."; + +/** + * SH-278: zero or one Service Area per vendor company, chosen from the Area + * catalogue by stable id. Clearing the selection leaves the company Unassigned. + * The saved Area stays visible when it is no longer in the active catalogue. + */ +export function VendorServiceAreaField({ + control, + areaOptions, + currentArea, + canAssign, +}: VendorServiceAreaFieldProps) { + const options = + currentArea && !areaOptions.some((area) => area.id === currentArea.id) + ? [...areaOptions, currentArea] + : areaOptions; + + return ( + ( + + options={options} + value={options.find((area) => area.id === field.value) ?? null} + onChange={(_event, option) => field.onChange(option?.id ?? "")} + onBlur={field.onBlur} + getOptionLabel={(option) => option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + disabled={!canAssign} + fullWidth + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx b/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx new file mode 100644 index 00000000..b562b42f --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx @@ -0,0 +1,57 @@ +import type { Ref } from "react"; +import { Autocomplete, TextField } from "@mui/material"; +import { + US_STATE_CODES, + formatStateOptionLabel, + normalizeStateCode, + type StateCode, +} from "@/data/areas"; + +interface VendorStateSelectFieldProps { + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + inputRef?: Ref; + error?: boolean; + helperText?: string; +} + +/** + * SH-278: closed, searchable list of the 50 US states for the vendor company. + * Legacy full names are shown as their canonical code. Service Area is chosen + * independently, so no Area is derived from the selected State here. + */ +export function VendorStateSelectField({ + value, + onChange, + onBlur, + inputRef, + error, + helperText, +}: VendorStateSelectFieldProps) { + const selected: StateCode | null = normalizeStateCode(value); + return ( + + options={US_STATE_CODES} + value={selected} + onChange={(_event, option) => onChange(option ?? "")} + onBlur={onBlur} + getOptionLabel={formatStateOptionLabel} + fullWidth + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-table-row.tsx b/src/app/(protected)/vendors/_components/vendor-table-row.tsx index e4ea83db..41e2b740 100644 --- a/src/app/(protected)/vendors/_components/vendor-table-row.tsx +++ b/src/app/(protected)/vendors/_components/vendor-table-row.tsx @@ -155,6 +155,7 @@ function CompanyCells({ row }: { row: VendorListItem }) { + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index acbf10c6..92b13609 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -21,6 +21,7 @@ const TABLE_COLUMNS = [ "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -66,7 +67,7 @@ export function VendorsTable({ size="small" stickyHeader sx={{ - minWidth: 2050, + minWidth: 2200, tableLayout: "fixed", "& .MuiTableCell-head": { backgroundColor: "var(--color-bg-muted)", @@ -94,7 +95,7 @@ export function VendorsTable({ }} > - {[190, 160, 220, 220, 140, 100, 190, 220, 170, 110, 120, 130].map((width, index) => ( + {[190, 160, 220, 220, 140, 100, 150, 190, 220, 170, 110, 120, 130].map((width, index) => ( ))} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a9968f39..9adeb936 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -13,7 +13,7 @@ import { VendorsTable } from "./_components/vendors-table"; import { useVendorDeactivation } from "./_components/use-vendor-deactivation"; import { useVendorListState } from "./_components/use-vendor-list-state"; import type { VendorFilters } from "./_components/vendor-filters"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import { VENDOR_AREA_UNASSIGNED, type VendorListItem } from "@/domain/vendors/types/vendor"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; @@ -91,6 +91,7 @@ function VendorStatusTabs({ function describeChips( filters: VendorFilters, jobBucketLabels: Map, + areaLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; filters.companies.forEach((value) => @@ -99,6 +100,13 @@ function describeChips( filters.trades.forEach((value) => chips.push({ category: "trades", value, label: `Trade: ${value}` }), ); + filters.areas.forEach((value) => + chips.push({ + category: "areas", + value, + label: `Service Area: ${areaLabels.get(value) ?? value}`, + }), + ); filters.locations.forEach((value) => chips.push({ category: "locations", value, label: `Location: ${value}` }), ); @@ -146,7 +154,15 @@ export default function VendorsListPage() { () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), [facets], ); - const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); + const areaLabels = useMemo( + () => + new Map([ + [VENDOR_AREA_UNASSIGNED, "Unassigned"], + ...(facets?.areas ?? []).map((area): [string, string] => [area.id, area.name]), + ]), + [facets], + ); + const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels, areaLabels); return ( diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index fda4215e..3d925b76 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -56,6 +56,7 @@ function buildListParams(params: VendorsListParams): Record area.id !== "" && area.name !== "") + : [], locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [], jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [], }; diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 27672d30..319c0696 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -92,10 +92,18 @@ export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster { zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), + areaId: readString(item, "areaId", "AreaId"), + areaName: readString(item, "areaName", "AreaName"), technicians: mapRosterTechnicians(item.technicians ?? item.Technicians), }; } +/** SH-278: form Area ids are strings; the API takes a positive integer id or null (Unassigned). */ +export function toBackendAreaId(value: unknown): number | null { + const parsed = Number(typeof value === "string" ? value.trim() : value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + export function mapRosterTechnicianToBackend(technician: unknown): Record { const item = asRecord(technician); const payload: Record = { @@ -130,6 +138,11 @@ export function mapVendorRosterToBackend(values: unknown): Record = { ...patch.companyFields }; if (companyFields.companyPhone !== undefined) companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone); + // SH-278: an edited Area is sent as an id or an explicit null (Unassigned). + if (companyFields.areaId !== undefined) + companyFields.areaId = toBackendAreaId(companyFields.areaId); payload.companyFields = companyFields; } return payload; diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts index fb05edee..d957d60d 100644 --- a/src/domain/vendors/schemas/vendor-roster-schema.ts +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -54,6 +54,8 @@ const baseCompanyFields = { notes: z .string() .max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`), + // SH-278: optional stable Area id; "" means Unassigned. Admin-only to change. + areaId: z.string(), technicians: z.array(rosterTechnicianSchema), }; @@ -107,5 +109,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = { state: "", zip: "", notes: "", + areaId: "", technicians: [], }; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index d7747afc..2449bcb3 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -30,6 +30,10 @@ export interface VendorListItem { tradeSpecialties: string; isActive: boolean; preferredContact: VendorPreferredContact; + /** SH-278 stable Area id as a string; "" when the company is Unassigned. */ + areaId: string; + /** SH-278 Area display name; "" when the company is Unassigned. */ + areaName: string; /** Present on company-grouped directory responses; optional for legacy clients/fixtures. */ technicians?: VendorDirectoryTechnician[]; } @@ -63,6 +67,8 @@ export interface VendorsListParams { trades?: string[]; locations?: string[]; jobBuckets?: string[]; + /** SH-278 stable Area ids or the "__unassigned__" sentinel. */ + areas?: string[]; } export interface VendorsListResult { @@ -95,9 +101,19 @@ export interface VendorFacetJobBucket { label: string; } +/** SH-278 active Area catalogue option. `id` is the stable areas[n] filter value. */ +export interface VendorFacetArea { + id: string; + name: string; +} + +/** SH-278 wire value that selects companies without an Area. */ +export const VENDOR_AREA_UNASSIGNED = "__unassigned__"; + export interface VendorFacets { companies: VendorFacetCompany[]; trades: string[]; + areas: VendorFacetArea[]; locations: VendorFacetLocation[]; jobBuckets: VendorFacetJobBucket[]; } @@ -147,6 +163,9 @@ export interface VendorCompanyRoster { zip: string; googleMapsUrl: string; notes: string; + /** SH-278 stable Area id as a string; "" when the company is Unassigned. */ + areaId: string; + areaName: string; technicians: VendorRosterTechnician[]; } diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts index fdfb055a..89e35054 100644 --- a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts +++ b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts @@ -38,6 +38,7 @@ const COMPANY_FIELDS = [ "state", "zip", "notes", + "areaId", ] as const; const TECHNICIAN_FIELDS = [ diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 3202bd40..031b589e 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -68,6 +68,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [], }; @@ -134,6 +136,7 @@ function submitValues( state: "MO", zip: "63101", notes: "", + areaId: "", technicians, }; } @@ -300,6 +303,7 @@ describe("useVendorRosterForm prototype defaults", () => { state: "", zip: "", notes: "", + areaId: "", technicians: [ { contactName: "", diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index e96fd031..355f99f1 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -12,6 +12,8 @@ import { type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; import { renderWithProviders } from "@/test/test-utils"; +import type { StateCode } from "@/data/areas"; +import { pickVendorState } from "./vendor-state-select"; vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }), @@ -58,16 +60,18 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); } -function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) { +function fillRequiredAddress({ + street = "6735 Trippel Rd", + city = "Theodore", + state = "AL" as StateCode, +} = {}) { fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), { target: { value: street }, }); fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { target: { value: city }, }); - fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { - target: { value: state }, - }); + pickVendorState(state); } describe("VendorCreateModal validation", () => { diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index 8777567d..0da2e959 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -39,6 +39,8 @@ const vendor: VendorListItem = { tradeSpecialties: "", isActive: true, preferredContact: "Phone", + areaId: "", + areaName: "", }; function rosterWith(technicians: Array>) { @@ -57,6 +59,8 @@ function rosterWith(technicians: Array>) { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians, }, isLoading: false, diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx index 28a3b106..2a3bcc81 100644 --- a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx @@ -11,11 +11,15 @@ import { } from "@/domain/vendors/schemas/vendor-roster-schema"; import { suggestAddresses } from "@/lib/address/vendor-address"; import { renderWithProviders } from "@/test/test-utils"; +import { formatStateOptionLabel, normalizeStateCode } from "@/data/areas"; +import { pickVendorState, stateField } from "./vendor-state-select"; const TYPED_STREET = "415 Murphy Rd"; const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0]; if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street"); +const FIRST_SUGGESTION_STATE = normalizeStateCode(FIRST_SUGGESTION.state); +if (FIRST_SUGGESTION_STATE == null) throw new Error("expected a canonical suggestion state"); function AddressFormHarness({ onValid, @@ -67,9 +71,7 @@ describe("VendorRosterFormFields address autocomplete", () => { expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( FIRST_SUGGESTION.city, ); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( - FIRST_SUGGESTION.state, - ); + expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE)); }); it("submits the settled structured parts, not the option label", async () => { @@ -104,14 +106,12 @@ describe("VendorRosterFormFields address autocomplete", () => { expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( FIRST_SUGGESTION.city, ); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( - FIRST_SUGGESTION.state, - ); + expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE)); await user.click(screen.getByRole("button", { name: "Clear" })); expect(street).toHaveValue(""); expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(""); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(""); + expect(stateField()).toHaveValue(""); }); }); @@ -132,9 +132,7 @@ describe("VendorRosterFormFields map preview empty state", () => { fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { target: { value: "Theodore" }, }); - fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { - target: { value: "AL" }, - }); + pickVendorState("AL"); expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); diff --git a/src/test/app/(protected)/vendors/vendor-service-area.test.tsx b/src/test/app/(protected)/vendors/vendor-service-area.test.tsx new file mode 100644 index 00000000..bb19416a --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-service-area.test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { SERVICE_AREA_ADMIN_ONLY_MESSAGE } from "@/app/(protected)/vendors/_components/vendor-service-area-field"; +import { VendorFilterOptions } from "@/app/(protected)/vendors/_components/vendor-filter-options"; +import { + emptyVendorFilters, + serviceAreaFilterOptions, +} from "@/app/(protected)/vendors/_components/vendor-filters"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor"; +import { + mapVendorRosterAdditivePatchToBackend, + mapVendorRosterToBackend, +} from "@/domain/vendors/mappers/vendor-roster-mapper"; +import { mapVendorFacets, mapVendorListItem } from "@/domain/vendors/mappers/vendor-mapper"; +import { renderWithProviders } from "@/test/test-utils"; +import { pickVendorState, stateField } from "./vendor-state-select"; + +const AREAS: VendorFacetArea[] = [ + { id: "1", name: "East" }, + { id: "2", name: "Central" }, +]; + +function Harness({ + canAssignArea, + currentArea = null, + defaults = {}, + onValid, +}: { + canAssignArea: boolean; + currentArea?: VendorFacetArea | null; + defaults?: Partial; + onValid: (values: VendorCompanyRosterFormValues) => void; +}) { + const { control, handleSubmit, formState } = useForm({ + defaultValues: { ...emptyVendorCompanyRosterForm, ...defaults }, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
+ + + + ); +} + +const COMPLETE_COMPANY: Partial = { + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + address: "1 Market St", + city: "St. Louis", + state: "MO", +}; + +describe("Vendor Service Area field", () => { + it("lets an admin assign one Service Area by stable id", async () => { + const onValid = vi.fn(); + renderWithProviders(, { + withAuth: false, + }); + + const area = screen.getByRole("combobox", { name: "Service Area (optional)" }); + expect(area).toBeEnabled(); + fireEvent.change(area, { target: { value: "Cen" } }); + fireEvent.click(screen.getByRole("option", { name: "Central" })); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("2"); + }); + + it("shows the saved Area read-only to non-admins and keeps it on submit", async () => { + const onValid = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + const area = screen.getByRole("combobox", { name: "Service Area (optional)" }); + expect(area).toBeDisabled(); + expect(area).toHaveValue("Archived Zone"); + expect(screen.getByText(SERVICE_AREA_ADMIN_ONLY_MESSAGE)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("9"); + }); + + it("does not derive the Service Area from the selected State", () => { + renderWithProviders(, { withAuth: false }); + + pickVendorState("CA"); + + expect(stateField()).toHaveValue("California (CA)"); + expect(screen.getByRole("combobox", { name: "Service Area (optional)" })).toHaveValue(""); + expect(screen.queryByText(/Area:/)).not.toBeInTheDocument(); + }); +}); + +describe("Vendor State field", () => { + it("is a closed list: free text is not kept and legacy names show as codes", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(stateField()).toHaveValue("Missouri (MO)"); + fireEvent.change(stateField(), { target: { value: "Ontario" } }); + expect(screen.queryByRole("option", { name: /Ontario/ })).not.toBeInTheDocument(); + fireEvent.blur(stateField()); + expect(stateField()).toHaveValue("Missouri (MO)"); + }); +}); + +describe("Service Area contracts", () => { + it("orders filter options Unassigned first, then names A–Z", () => { + expect( + serviceAreaFilterOptions([ + { id: "3", name: "West" }, + { id: "4", name: "california" }, + { id: "1", name: "East" }, + ]).map((option) => option.id), + ).toEqual([VENDOR_AREA_UNASSIGNED, "4", "1", "3"]); + }); + + it("maps facet areas and directory Area fields from the API", () => { + expect( + mapVendorFacets({ + Areas: [ + { Id: 1, Name: "East" }, + { Id: 0, Name: "" }, + ], + }).areas, + ).toEqual([{ id: "1", name: "East" }]); + const row = mapVendorListItem({ Id: 1, AreaId: 2, AreaName: "Central" }); + expect(row.areaId).toBe("2"); + expect(row.areaName).toBe("Central"); + }); + + it("round-trips form Areas, sends explicit null for Unassigned, and omits absent Areas", () => { + expect(mapVendorRosterToBackend({ name: "A", areaId: "3" }).areaId).toBe(3); + expect(mapVendorRosterToBackend({ name: "A", areaId: "" }).areaId).toBeNull(); + expect(mapVendorRosterToBackend({ name: "A" })).not.toHaveProperty("areaId"); + expect( + mapVendorRosterAdditivePatchToBackend({ + rowVersion: "rv", + addTechnicians: [], + companyFields: { areaId: "" }, + }).companyFields, + ).toEqual({ areaId: null }); + expect( + mapVendorRosterAdditivePatchToBackend({ rowVersion: "rv", addTechnicians: [] }), + ).not.toHaveProperty("companyFields"); + }); +}); + +describe("Vendor Service Area filter section", () => { + it("sits between Trade and Location", () => { + const setDraft = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([ + "Company", + "Trade", + "Service Area", + "Location", + "Total Jobs", + ]); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-state-select.ts b/src/test/app/(protected)/vendors/vendor-state-select.ts new file mode 100644 index 00000000..640654ad --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-state-select.ts @@ -0,0 +1,15 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { formatStateOptionLabel, type StateCode } from "@/data/areas"; + +export const STATE_FIELD_NAME = "State (required)"; + +export function stateField(): HTMLElement { + return screen.getByRole("combobox", { name: STATE_FIELD_NAME }); +} + +/** SH-278: picks a state from the closed vendor State list by its option label. */ +export function pickVendorState(code: StateCode): void { + const label = formatStateOptionLabel(code); + fireEvent.change(stateField(), { target: { value: label } }); + fireEvent.click(screen.getByRole("option", { name: label })); +} diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index ab905729..2b98c7f0 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -46,6 +46,8 @@ const activeVendor: VendorListItem = { tradeSpecialties: "Backflow Preventers", isActive: true, preferredContact: "Email", + areaId: "", + areaName: "", }; const inactiveVendor = { @@ -67,6 +69,8 @@ const activeRoster: VendorCompanyRoster = { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [ { id: 1, @@ -668,4 +672,30 @@ describe("VendorsListPage", () => { expect(screen.getByRole("tab", { name: "Active (3)" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Inactive (2)" })).toBeInTheDocument(); }); + + it("shows the Service Area column after State with Unassigned for companies without an Area", () => { + setupDefaults(); + const assigned: VendorListItem = { ...activeVendor, areaId: "1", areaName: "East" }; + const unassigned: VendorListItem = { + ...activeVendor, + id: 9, + companyId: "co-9", + companyName: "No Area Co", + }; + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 2); + return params.isActive ? result([assigned, unassigned], 2) : result([], 0); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + const headers = screen.getAllByRole("columnheader").map((header) => header.textContent); + expect(headers.indexOf("Service Area")).toBe(headers.indexOf("State") + 1); + expect(headers.indexOf("Trade")).toBe(headers.indexOf("Service Area") + 1); + expect( + screen.getAllByTestId("vendor-text-service-area").map((cell) => cell.textContent), + ).toEqual(["East", "Unassigned"]); + }); }); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index 8b33239e..38a6bd20 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -419,3 +419,24 @@ describe("vendorsApi.getDirectoryList", () => { }); }); }); + +describe("vendorsApi.getDirectoryList Service Area filter", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ Data: [], TotalCount: 0 }); + }); + + it("sends areas[n] stable ids and the Unassigned sentinel", async () => { + await vendorsApi.getDirectoryList({ page: 1, areas: ["1", "__unassigned__"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "areas[0]": "1", + "areas[1]": "__unassigned__", + }, + }); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 4c9a463f..3f4beef9 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -40,6 +40,8 @@ describe("vendor roster mapper", () => { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [ { id: 7, diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx index aaec2167..b6dcae4d 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -40,6 +40,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "", notes: "Preferred vendor", + areaId: "", + areaName: "", technicians: [ { id: 11, @@ -75,6 +77,7 @@ function formValues( state: roster.state, zip: roster.zip, notes: roster.notes, + areaId: roster.areaId, technicians: [newTechnician], ...overrides, }; diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts index 735ef2d7..3aabb058 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -14,6 +14,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "http://maps.example.test/legacy", notes: "Legacy values remain untouched", + areaId: "", + areaName: "", technicians: [ { id: 7, @@ -37,6 +39,7 @@ const values = { state: roster.state, zip: roster.zip, notes: roster.notes, + areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ id: technician.id, contactName: technician.contactName,