From 90a3c24846e042e2e1ef5d947a6c047675f85abd Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 17 Sep 2026 14:14:48 -0300 Subject: [PATCH] fix(services): source Trade picker from canonical catalog (SH-304) The Trade select was fed from the editable DropdownOptions "Trade" category, whose legacy seed values ("Plumbing", "General", ...) are not in the backend TradeCatalog. ServicesRegistryService.RequireCanonicalTrade rejects any non-canonical trade with 400 TradeInvalid, so Add Service and Save Changes failed for legacy names and an admin editing the Trade dropdown could break service creation at any time. Source the picker from the server-owned canonical Trade reference on Vendor/facets (the same list the vendor roster form uses), key the icon defaults to the canonical names, and keep the edited service's stored trade selectable if it was later deactivated so editing never silently blanks the field. --- .../services-registry-components.tsx | 7 ++--- .../use-services-registry-controller.ts | 27 +++++++++++++--- .../settings/services-registry-view.test.tsx | 31 +++++++++++++++++-- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/app/(protected)/settings/_components/services-registry-components.tsx b/src/app/(protected)/settings/_components/services-registry-components.tsx index 5762a7f4..e7145bec 100644 --- a/src/app/(protected)/settings/_components/services-registry-components.tsx +++ b/src/app/(protected)/settings/_components/services-registry-components.tsx @@ -23,7 +23,6 @@ import { Typography, } from "@mui/material"; import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react"; -import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option"; import { SERVICE_WORK_ORDER_TYPES, type CompletionDocTemplateOption, @@ -141,7 +140,7 @@ type EditorProps = { mode: "add" | "edit" | null; form: ServiceForm; formError: string; - trades: DropdownOption[]; + trades: string[]; templates: CompletionDocTemplateOption[]; canChangeActiveState: boolean; isSaving: boolean; @@ -238,8 +237,8 @@ function ServiceEditorFields({ onChange={(event) => onUpdate("trade", event.target.value)} > {trades.map((trade) => ( - - {trade.value} + + {trade} ))} diff --git a/src/domain/services/use-cases/use-services-registry-controller.ts b/src/domain/services/use-cases/use-services-registry-controller.ts index 325a7609..43237a48 100644 --- a/src/domain/services/use-cases/use-services-registry-controller.ts +++ b/src/domain/services/use-cases/use-services-registry-controller.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useAuthContext } from "@/providers/auth-context"; import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service"; import { useCreateService, @@ -25,11 +25,14 @@ export const EMPTY_SERVICE_FORM: ServiceForm = { supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], }; +// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not +// the legacy DropdownOptions seed. A trade with no explicit default falls back +// to "wrench" where this map is read. export const DEFAULT_ICON_BY_TRADE: Record = { HVAC: "droplets", - Plumbing: "droplets", + "Plumbing & Water Systems": "droplets", Electrical: "zap", - "General Building": "hammer", + "General Building & Handyman": "hammer", }; export function buildServiceInput( @@ -98,7 +101,13 @@ export function useServicesRegistryController() { const [deactivateTarget, setDeactivateTarget] = useState(null); const servicesQuery = useServices(); - const { data: trades = [] } = useDropdownOptionsByCategory("Trade"); + // The backend rejects any Trade outside the canonical TradeCatalog (400 + // TradeInvalid), so the picker is sourced from the server-owned canonical + // Trade reference exposed on Vendor/facets — the same list the vendor roster + // form uses — never the editable DropdownOptions "Trade" category, whose + // legacy seed values ("Plumbing", "General", ...) would fail on save. + const facetsQuery = useVendorFacets(); + const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]); const { data: templates = [] } = useCompletionDocTemplates(); const createService = useCreateService(); const updateService = useUpdateService(); @@ -120,6 +129,16 @@ export function useServicesRegistryController() { ); }, [search, services, status]); + // Keep the trade stored on the service being edited selectable even if it was + // later deactivated in the canonical Trade catalog, so editing never silently + // blanks the field and forces an unrelated trade change. + const trades = useMemo(() => { + if (form.trade && !canonicalTrades.includes(form.trade)) { + return [form.trade, ...canonicalTrades]; + } + return canonicalTrades; + }, [canonicalTrades, form.trade]); + useEffect(() => { if (!iconTouched && form.trade) { setForm((current) => ({ diff --git a/src/test/app/(protected)/settings/services-registry-view.test.tsx b/src/test/app/(protected)/settings/services-registry-view.test.tsx index 532f0aa4..a696f9eb 100644 --- a/src/test/app/(protected)/settings/services-registry-view.test.tsx +++ b/src/test/app/(protected)/settings/services-registry-view.test.tsx @@ -24,8 +24,16 @@ vi.mock("@/domain/services/use-cases/use-services", () => ({ useDeactivateService: () => ({ mutate, isPending: false }), })); -vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ - useDropdownOptionsByCategory: () => ({ data: [] }), +vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ + useVendorFacets: () => ({ + data: { + companies: [], + trades: ["Plumbing & Water Systems", "Electrical", "HVAC", "General Building & Handyman"], + areas: [], + locations: [], + jobBuckets: [], + }, + }), })); const activeHvac: Service = { @@ -115,6 +123,25 @@ describe("ServicesRegistryView", () => { expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument(); }); + it("offers only canonical trades and sends the selected canonical trade to the create payload", async () => { + setup(); + + await userEvent.click(screen.getByRole("button", { name: "Add Service" })); + await userEvent.type(screen.getByRole("textbox", { name: "Service Name" }), "Panel Swap"); + + await userEvent.click(screen.getByRole("combobox", { name: "Trade" })); + // Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not. + expect(screen.getByRole("option", { name: "Plumbing & Water Systems" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Plumbing" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("option", { name: "Electrical" })); + + const saveButtons = screen.getAllByRole("button", { name: "Add Service" }); + await userEvent.click(saveButtons[saveButtons.length - 1]); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ trade: "Electrical" }); + }); + it("summarizes the filtered count and the overall split in the footer without a search", () => { setup();