mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 13:53:22 +00:00
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.
255 lines
8.1 KiB
TypeScript
255 lines
8.1 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,
|
|
};
|
|
}
|
|
|
|
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 [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("");
|
|
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 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]);
|
|
|
|
// 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);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
const confirmDeactivate = () => {
|
|
if (!deactivateTarget) return;
|
|
deactivateService.mutate(deactivateTarget.id, {
|
|
onSuccess: () => {
|
|
setDeactivateTarget(null);
|
|
setSelected(null);
|
|
},
|
|
});
|
|
};
|
|
|
|
return {
|
|
canManage,
|
|
canDeactivate,
|
|
status,
|
|
setStatus,
|
|
search,
|
|
setSearch,
|
|
selected,
|
|
setSelected,
|
|
editorMode,
|
|
form,
|
|
formError,
|
|
trades,
|
|
templates,
|
|
filteredServices,
|
|
services,
|
|
activeCount,
|
|
inactiveCount,
|
|
isLoading: servicesQuery.isLoading,
|
|
error: servicesQuery.error,
|
|
openCreate,
|
|
openEdit,
|
|
closeEditor,
|
|
updateForm,
|
|
toggleType,
|
|
save,
|
|
deactivateTarget,
|
|
setDeactivateTarget,
|
|
confirmDeactivate,
|
|
isSaving: createService.isPending || updateService.isPending,
|
|
isDeactivating: deactivateService.isPending,
|
|
};
|
|
}
|