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();