mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 22:33:13 +00:00
useServicesRegistryController exceeded the changed-file max-lines-per-function budget (158 > 150). Move the add/edit form lifecycle into a useServiceEditor sub-hook; the controller's public return shape and behavior are unchanged.
283 lines
9 KiB
TypeScript
283 lines
9 KiB
TypeScript
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<ServiceInput, "completionDocTemplateId"> & {
|
|
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<string, string> = {
|
|
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<typeof useCreateService>,
|
|
updateService: ReturnType<typeof useUpdateService>,
|
|
) {
|
|
const [selected, setSelected] = useState<Service | null>(null);
|
|
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
|
|
const [form, setForm] = useState<ServiceForm>(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 = <K extends keyof ServiceForm>(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<ServiceStatus>("active");
|
|
const [search, setSearch] = useState("");
|
|
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(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,
|
|
};
|
|
}
|