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.
This commit is contained in:
Codex Review Integration 2026-09-17 14:14:48 -03:00
parent 7dd7ea497e
commit 90a3c24846
3 changed files with 55 additions and 10 deletions

View file

@ -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) => (
<MenuItem key={String(trade.id)} value={trade.value}>
{trade.value}
<MenuItem key={trade} value={trade}>
{trade}
</MenuItem>
))}
</Select>

View file

@ -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<string, string> = {
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<Service | null>(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) => ({

View file

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