import { useEffect, useMemo, useState } from "react"; import { useAuthContext } from "@/providers/auth-context"; import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service"; import { useCreateService, useCompletionDocTemplates, useDeactivateService, useServices, useUpdateService, } from "@/domain/services/use-cases/use-services"; export type ServiceStatus = "active" | "inactive"; export type ServiceForm = Omit & { completionDocTemplateId: string | number | null; }; export const EMPTY_SERVICE_FORM: ServiceForm = { name: "", trade: "", iconKey: "", requiresCompletionDocument: false, completionDocTemplateId: null, 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 & Water Systems": "droplets", Electrical: "zap", "General Building & Handyman": "hammer", }; export function buildServiceInput( form: ServiceForm, includeActiveState = false, ): { input: ServiceInput | null; error: string; } { const name = form.name.trim(); if (!name || !form.trade || !form.iconKey) { return { input: null, error: "Service Name, Trade, and Icon are required." }; } if (!form.supportedWorkOrderTypes.length) { return { input: null, error: "Select at least one Work Order Type." }; } if (form.requiresCompletionDocument && form.completionDocTemplateId == null) { return { input: null, error: "Select a completion document template when the document is required.", }; } const templateId = form.completionDocTemplateId; const { isActive, ...editableFields } = form; return { input: { ...editableFields, name, completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId), ...(includeActiveState && isActive !== undefined ? { isActive } : {}), }, error: "", }; } function emptyForm(): ServiceForm { return { ...EMPTY_SERVICE_FORM, supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes], }; } function formFromService(service: Service): ServiceForm { return { name: service.name, trade: service.trade, iconKey: service.iconKey, requiresCompletionDocument: service.requiresCompletionDocument, completionDocTemplateId: service.completionDocTemplate?.id ?? null, supportedWorkOrderTypes: [...service.supportedWorkOrderTypes], isActive: service.isActive, }; } // Owns the add/edit form lifecycle so the top-level controller stays within the // changed-file maintainability budget. Mutations are passed in so `isSaving` and // `closeEditor` share the exact instances the controller reports on. function useServiceEditor( canonicalTrades: string[], canDeactivate: boolean, createService: ReturnType, updateService: ReturnType, ) { const [selected, setSelected] = useState(null); const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null); const [form, setForm] = useState(emptyForm); const [iconTouched, setIconTouched] = useState(false); const [formError, setFormError] = useState(""); // 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) => ({ ...current, iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench", })); } }, [form.trade, iconTouched]); const openCreate = () => { setSelected(null); setForm(emptyForm()); setIconTouched(false); setFormError(""); setEditorMode("add"); }; const openEdit = (service: Service) => { setSelected(service); setForm(formFromService(service)); setIconTouched(true); setFormError(""); setEditorMode("edit"); }; const closeEditor = () => { if (!createService.isPending && !updateService.isPending) setEditorMode(null); }; const updateForm = (key: K, value: ServiceForm[K]) => { if (key === "trade") setIconTouched(false); setFormError(""); setForm((current) => ({ ...current, [key]: value })); }; const toggleType = (type: ServiceWorkOrderType) => { setForm((current) => { const supported = current.supportedWorkOrderTypes.includes(type) ? current.supportedWorkOrderTypes.filter((item) => item !== type) : [...current.supportedWorkOrderTypes, type]; return { ...current, supportedWorkOrderTypes: supported }; }); }; const save = () => { const validation = buildServiceInput(form, canDeactivate); if (!validation.input) { setFormError(validation.error); return; } const input = validation.input; if (editorMode === "add") { createService.mutate(input, { onSuccess: (created) => { setEditorMode(null); setSelected(created); }, }); } else if (editorMode === "edit" && selected) { updateService.mutate( { id: selected.id, input }, { onSuccess: (updated) => { setEditorMode(null); setSelected(updated); }, }, ); } }; return { selected, setSelected, editorMode, form, formError, trades, openCreate, openEdit, closeEditor, updateForm, toggleType, save, }; } export function useServicesRegistryController() { const { user } = useAuthContext(); const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles); const canDeactivate = isAdminUser(user?.userRoles); const [status, setStatus] = useState("active"); const [search, setSearch] = useState(""); const [deactivateTarget, setDeactivateTarget] = useState(null); const servicesQuery = useServices(); // 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(); const deactivateService = useDeactivateService(); const editor = useServiceEditor(canonicalTrades, canDeactivate, createService, updateService); const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]); const activeCount = services.filter((service) => service.isActive).length; const inactiveCount = services.length - activeCount; const filteredServices = useMemo(() => { const needle = search.trim().toLowerCase(); return services .filter((service) => (status === "active" ? service.isActive : !service.isActive)) .filter( (service) => !needle || service.name.toLowerCase().includes(needle) || service.trade.toLowerCase().includes(needle), ); }, [search, services, status]); const confirmDeactivate = () => { if (!deactivateTarget) return; deactivateService.mutate(deactivateTarget.id, { onSuccess: () => { setDeactivateTarget(null); editor.setSelected(null); }, }); }; return { canManage, canDeactivate, status, setStatus, search, setSearch, selected: editor.selected, setSelected: editor.setSelected, editorMode: editor.editorMode, form: editor.form, formError: editor.formError, trades: editor.trades, templates, filteredServices, services, activeCount, inactiveCount, isLoading: servicesQuery.isLoading, error: servicesQuery.error, openCreate: editor.openCreate, openEdit: editor.openEdit, closeEditor: editor.closeEditor, updateForm: editor.updateForm, toggleType: editor.toggleType, save: editor.save, deactivateTarget, setDeactivateTarget, confirmDeactivate, isSaving: createService.isPending || updateService.isPending, isDeactivating: deactivateService.isPending, }; }