diff --git a/src/app/(protected)/settings/_components/services-registry-components.tsx b/src/app/(protected)/settings/_components/services-registry-components.tsx
new file mode 100644
index 00000000..81fff5cb
--- /dev/null
+++ b/src/app/(protected)/settings/_components/services-registry-components.tsx
@@ -0,0 +1,342 @@
+import {
+ Autocomplete,
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Divider,
+ Drawer,
+ FormControl,
+ FormControlLabel,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+ Stack,
+ Switch,
+ TextField,
+ 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 type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
+import {
+ SERVICE_WORK_ORDER_TYPES,
+ type Service,
+ type ServiceWorkOrderType,
+} from "@/domain/services/types/service";
+import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
+
+const ICONS = [
+ { key: "wrench", label: "Wrench", Icon: Wrench },
+ { key: "droplets", label: "Droplets", Icon: Droplets },
+ { key: "zap", label: "Zap", Icon: Zap },
+ { key: "hammer", label: "Hammer", Icon: Hammer },
+ { key: "settings", label: "Settings", Icon: Settings2 },
+] as const;
+
+export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
+ const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
+ const Icon = icon.Icon;
+ return ;
+}
+
+type DetailsProps = {
+ service: Service | null;
+ open: boolean;
+ canManage: boolean;
+ canDeactivate: boolean;
+ onClose: () => void;
+ onEdit: (service: Service) => void;
+ onDeactivate: (service: Service) => void;
+};
+
+export function ServiceDetailsDrawer({
+ service,
+ open,
+ canManage,
+ canDeactivate,
+ onClose,
+ onEdit,
+ onDeactivate,
+}: DetailsProps) {
+ return (
+
+ {service !== null && (
+
+
+
+
+ {service.name}
+
+
+ Service details
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Trade
+
+
+ {service.trade}
+
+
+
+
+
+ Work Order Types
+
+
+ {service.supportedWorkOrderTypes.map((type) => (
+
+ ))}
+
+
+
+
+ Completion Document
+
+
+ {service.requiresCompletionDocument
+ ? service.completionDocTemplate?.name || "No template linked yet"
+ : "Not required"}
+
+
+
+
+
+
+ {canManage && (
+
+ )}
+ {canDeactivate && service.isActive && (
+
+ )}
+
+
+ )}
+
+ );
+}
+
+type EditorProps = {
+ mode: "add" | "edit" | null;
+ form: ServiceForm;
+ formError: string;
+ trades: DropdownOption[];
+ templates: TaskTemplate[];
+ isSaving: boolean;
+ onClose: () => void;
+ onSave: () => void;
+ onUpdate: (key: K, value: ServiceForm[K]) => void;
+ onToggleType: (type: ServiceWorkOrderType) => void;
+};
+
+export function ServiceEditorDrawer({
+ mode,
+ form,
+ formError,
+ trades,
+ templates,
+ isSaving,
+ onClose,
+ onSave,
+ onUpdate,
+ onToggleType,
+}: EditorProps) {
+ return (
+
+
+
+
+ {mode === "add" ? "Add Service" : "Edit Service"}
+
+
+ Register a service and whether it needs a completion document.
+
+
+
+
+
+ {Boolean(formError) && }
+
+ Service Information
+
+ onUpdate("name", event.target.value)}
+ placeholder="e.g., HVAC Inspection"
+ />
+
+ Trade
+
+
+
+ Icon
+
+ Pre-selected from the Trade; you can change it.
+
+
+ Supported Work Order Types
+
+
+ {SERVICE_WORK_ORDER_TYPES.map((type) => (
+ onToggleType(type)}
+ />
+ }
+ label={type}
+ />
+ ))}
+
+
+
+ Completion Document
+
+ {
+ onUpdate("requiresCompletionDocument", event.target.checked);
+ if (!event.target.checked) onUpdate("completionDocTemplateId", null);
+ }}
+ />
+ }
+ label="Requires completion document"
+ />
+ {form.requiresCompletionDocument && (
+ template.id === form.completionDocTemplateId) ?? null
+ }
+ onChange={(_, template) =>
+ onUpdate("completionDocTemplateId", template?.id ?? null)
+ }
+ getOptionLabel={(template) => template.name}
+ isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
+ renderInput={(params) => (
+
+ )}
+ />
+ )}
+
+ Manage the document's own content on the Completion Document Templates page.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function AlertMessage({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
+
+type DeactivateProps = {
+ service: Service | null;
+ open: boolean;
+ isPending: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+};
+
+export function DeactivateServiceDialog({
+ service,
+ open,
+ isPending,
+ onClose,
+ onConfirm,
+}: DeactivateProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/settings/_components/services-registry-view.tsx b/src/app/(protected)/settings/_components/services-registry-view.tsx
new file mode 100644
index 00000000..6c8c2d23
--- /dev/null
+++ b/src/app/(protected)/settings/_components/services-registry-view.tsx
@@ -0,0 +1,201 @@
+import {
+ Alert,
+ Box,
+ Button,
+ CircularProgress,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { Plus } from "lucide-react";
+import { SettingsNav } from "@/components/common/settings-nav";
+import {
+ useServicesRegistryController,
+ type ServiceStatus,
+} from "@/domain/services/use-cases/use-services-registry-controller";
+import {
+ DeactivateServiceDialog,
+ ServiceDetailsDrawer,
+ ServiceEditorDrawer,
+ ServiceIcon,
+} from "@/app/(protected)/settings/_components/services-registry-components";
+
+export function ServicesRegistryView() {
+ const controller = useServicesRegistryController();
+ const activeCount = controller.services.filter((service) => service.isActive).length;
+
+ return (
+
+
+
+
+
+ Services
+
+
+ {controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
+ {activeCount} active
+
+
+ {controller.canManage && (
+ }
+ >
+ Add Service
+
+ )}
+
+
+
+ controller.setSearch(event.target.value)}
+ placeholder="Search services..."
+ className="flex-1"
+ aria-label="Search services"
+ />
+
+ Status
+
+
+
+
+ {Boolean(controller.error) && (
+ {controller.error?.message || "Failed to load services"}
+ )}
+ {controller.isLoading ? (
+
+
+
+ ) : controller.filteredServices.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Service
+ Trade
+ Completion Document
+ Status
+
+
+
+
+ {controller.filteredServices.map((service) => (
+ controller.setSelected(service)}
+ sx={{ cursor: "pointer" }}
+ >
+
+
+
+
+
+
+
+ {service.name}
+
+
+ {service.supportedWorkOrderTypes.join(" · ")}
+
+
+
+
+ {service.trade}
+
+ {service.requiresCompletionDocument
+ ? service.completionDocTemplate?.name || "No template linked yet"
+ : "Not required"}
+
+ {service.isActive ? "Active" : "Inactive"}
+ ›
+
+ ))}
+
+
+
+ )}
+
+ controller.setSelected(null)}
+ onEdit={controller.openEdit}
+ onDeactivate={controller.setDeactivateTarget}
+ />
+
+ controller.setDeactivateTarget(null)}
+ onConfirm={controller.confirmDeactivate}
+ />
+
+ );
+}
+
+function EmptyServices({ search, onAdd }: { search: boolean; onAdd?: () => void }) {
+ return (
+
+
+ {search ? "No services found" : "No services configured yet"}
+
+
+ {search
+ ? "Try a different search, or add a new service."
+ : "Add a service to make it available for work orders."}
+
+ {onAdd !== undefined && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/settings/services.tsx b/src/app/(protected)/settings/services.tsx
index 5fcb4ebd..2c30fcf9 100644
--- a/src/app/(protected)/settings/services.tsx
+++ b/src/app/(protected)/settings/services.tsx
@@ -1,611 +1,5 @@
-import { useEffect, useMemo, useState } from "react";
-import {
- Alert,
- Autocomplete,
- Box,
- Button,
- Checkbox,
- Chip,
- CircularProgress,
- Dialog,
- DialogActions,
- DialogContent,
- DialogContentText,
- DialogTitle,
- Divider,
- Drawer,
- FormControl,
- FormControlLabel,
- FormHelperText,
- InputLabel,
- MenuItem,
- Select,
- Stack,
- Switch,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- TextField,
- Typography,
-} from "@mui/material";
-import { ChevronRight, Circle, Droplets, Hammer, Plus, Settings2, Wrench, Zap } from "lucide-react";
-import { SettingsNav } from "@/components/common/settings-nav";
-import { useAuthContext } from "@/providers/auth-context";
-import { getPrimaryUserRole, isAdminUser } from "@/lib/auth/user-utils";
-import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
-import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
-import {
- SERVICE_WORK_ORDER_TYPES,
- type Service,
- type ServiceInput,
- type ServiceWorkOrderType,
-} from "@/domain/services/types/service";
-import {
- useCreateService,
- useDeactivateService,
- useServices,
- useUpdateService,
-} from "@/domain/services/use-cases/use-services";
-
-type ServiceStatus = "all" | "active" | "inactive";
-type ServiceForm = Omit & {
- completionDocTemplateId: string | number | null;
-};
-
-const EMPTY_FORM: ServiceForm = {
- name: "",
- trade: "",
- iconKey: "",
- requiresCompletionDocument: false,
- completionDocTemplateId: null,
- supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
-};
-
-const DEFAULT_ICON_BY_TRADE: Record = {
- HVAC: "droplets",
- Plumbing: "droplets",
- Electrical: "zap",
- "General Building": "hammer",
-};
-
-const ICONS = [
- { key: "wrench", label: "Wrench", Icon: Wrench },
- { key: "droplets", label: "Droplets", Icon: Droplets },
- { key: "zap", label: "Zap", Icon: Zap },
- { key: "hammer", label: "Hammer", Icon: Hammer },
- { key: "settings", label: "Settings", Icon: Settings2 },
-] as const;
-
-function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
- const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
- const Icon = icon.Icon;
- return ;
-}
-
-function emptyForm(): ServiceForm {
- return { ...EMPTY_FORM, supportedWorkOrderTypes: [...EMPTY_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],
- };
-}
+import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
export default function ServicesPage() {
- const { user } = useAuthContext();
- const role = getPrimaryUserRole(user?.userRoles).toLowerCase();
- const canManage = isAdminUser(user?.userRoles) || role === "scheduler";
- const canDeactivate = isAdminUser(user?.userRoles);
- const [status, setStatus] = useState("active");
- const [search, setSearch] = useState("");
- 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("");
- const [deactivateTarget, setDeactivateTarget] = useState(null);
-
- const queryStatus = status === "active" ? true : status === "inactive" ? false : undefined;
- const { data: services = [], isLoading, error } = useServices(queryStatus);
- const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
- const { data: templates = [] } = useTaskTemplates();
- const createService = useCreateService();
- const updateService = useUpdateService();
- const deactivateService = useDeactivateService();
-
- const filteredServices = useMemo(() => {
- const needle = search.trim().toLowerCase();
- if (!needle) return services;
- return services.filter(
- (service) =>
- service.name.toLowerCase().includes(needle) || service.trade.toLowerCase().includes(needle),
- );
- }, [search, services]);
-
- 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]) => {
- 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 buildInput = (): ServiceInput | null => {
- const name = form.name.trim();
- if (!name || !form.trade || !form.iconKey) {
- setFormError("Service Name, Trade, and Icon are required.");
- return null;
- }
- if (!form.supportedWorkOrderTypes.length) {
- setFormError("Select at least one Work Order Type.");
- return null;
- }
- if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
- setFormError("Select a completion document template when the document is required.");
- return null;
- }
- const templateId = form.completionDocTemplateId;
- return {
- ...form,
- name,
- completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
- };
- };
-
- const save = () => {
- const input = buildInput();
- if (!input) return;
- 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) => setSelected(updated) },
- );
- }
- };
-
- const confirmDeactivate = () => {
- if (!deactivateTarget) return;
- deactivateService.mutate(deactivateTarget.id, {
- onSuccess: () => {
- setDeactivateTarget(null);
- setSelected(null);
- },
- });
- };
-
- const activeCount = services.filter((service) => service.isActive).length;
- const isSaving = createService.isPending || updateService.isPending;
-
- return (
-
-
-
-
-
- Services
-
-
- {services.length} service{services.length === 1 ? "" : "s"} · {activeCount} active
-
-
- {canManage && (
- }>
- Add Service
-
- )}
-
-
-
- setSearch(event.target.value)}
- placeholder="Search services..."
- className="flex-1"
- aria-label="Search services"
- />
-
- Status
-
-
-
-
- {Boolean(error) && (
- {error?.message || "Failed to load services"}
- )}
- {isLoading ? (
-
-
-
- ) : filteredServices.length === 0 ? (
-
- ) : (
-
-
-
-
- Service
- Trade
- Completion Document
- Status
-
-
-
-
- {filteredServices.map((service) => (
- setSelected(service)}
- sx={{ cursor: "pointer" }}
- >
-
-
-
-
-
-
-
- {service.name}
-
-
- {service.supportedWorkOrderTypes.join(" · ")}
-
-
-
-
- {service.trade}
-
- {service.requiresCompletionDocument
- ? service.completionDocTemplate?.name || "No template linked yet"
- : "Not required"}
-
-
-
-
-
-
-
-
- ))}
-
-
-
- )}
-
- setSelected(null)}
- >
- {selected !== null && (
-
-
-
-
- {selected.name}
-
-
- Service details
-
-
-
-
-
-
-
-
-
-
-
-
-
- Trade
-
-
- {selected.trade}
-
-
-
-
-
- Work Order Types
-
-
- {selected.supportedWorkOrderTypes.map((type) => (
-
- ))}
-
-
-
-
- Completion Document
-
-
- {selected.requiresCompletionDocument
- ? selected.completionDocTemplate?.name || "No template linked yet"
- : "Not required"}
-
-
-
-
-
-
- {canManage && (
-
- )}
- {canDeactivate && selected.isActive && (
-
- )}
-
-
- )}
-
-
-
-
-
-
- {editorMode === "add" ? "Add Service" : "Edit Service"}
-
-
- Register a service and whether it needs a completion document.
-
-
-
-
-
- {Boolean(formError) && {formError}}
-
- Service Information
-
- updateForm("name", event.target.value)}
- placeholder="e.g., HVAC Inspection"
- />
-
- Trade
-
-
-
- Icon
-
- Pre-selected from the Trade; you can change it.
-
-
-
- Supported Work Order Types
-
-
- {SERVICE_WORK_ORDER_TYPES.map((type) => (
- toggleType(type)}
- />
- }
- label={type}
- />
- ))}
-
-
-
-
- Completion Document
-
- {
- updateForm("requiresCompletionDocument", event.target.checked);
- if (!event.target.checked) updateForm("completionDocTemplateId", null);
- }}
- />
- }
- label="Requires completion document"
- />
- {form.requiresCompletionDocument && (
- template.id === form.completionDocTemplateId) ??
- null
- }
- onChange={(_, template) =>
- updateForm("completionDocTemplateId", template?.id ?? null)
- }
- getOptionLabel={(template) => template.name}
- isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
- renderInput={(params) => (
-
- )}
- />
- )}
-
- Manage the document's own content on the Completion Document Templates page.
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function PaperEmpty({ search, onAdd }: { search: boolean; onAdd?: () => void }) {
- return (
-
-
-
- {search ? "No services found" : "No services configured yet"}
-
-
- {search
- ? "Try a different search, or add a new service."
- : "Add a service to make it available for work orders."}
-
- {Boolean(onAdd) && (
-
- )}
-
- );
+ return ;
}
diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts
index 4a150527..8c5aa96d 100644
--- a/src/domain/services/api/services-api.ts
+++ b/src/domain/services/api/services-api.ts
@@ -4,6 +4,21 @@ import { handleApiResponse } from "@/api/handle-api-response";
import { mapService, mapServices } from "@/domain/services/mappers/service-mapper";
import type { Service, ServiceInput } from "@/domain/services/types/service";
+const SERVICE_WORK_ORDER_TYPE_API = {
+ PM: 2,
+ Emergency: 3,
+ Reactive: 6,
+} as const;
+
+function toApiInput(input: ServiceInput) {
+ return {
+ ...input,
+ supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
+ (type) => SERVICE_WORK_ORDER_TYPE_API[type],
+ ),
+ };
+}
+
export const servicesApi = {
getAll: async (isActive?: boolean): Promise => {
const data = await apiGet(API_PATHS.services.list, {
@@ -13,12 +28,12 @@ export const servicesApi = {
},
create: async (input: ServiceInput): Promise => {
- const data = await apiPost(API_PATHS.services.list, input);
+ const data = await apiPost(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise => {
- const data = await apiPut(API_PATHS.services.byId(id), input);
+ const data = await apiPut(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},
diff --git a/src/domain/services/use-cases/use-services-registry-controller.ts b/src/domain/services/use-cases/use-services-registry-controller.ts
new file mode 100644
index 00000000..51dc19ae
--- /dev/null
+++ b/src/domain/services/use-cases/use-services-registry-controller.ts
@@ -0,0 +1,223 @@
+import { useEffect, useMemo, useState } from "react";
+import { useAuthContext } from "@/providers/auth-context";
+import { getPrimaryUserRole, isAdminUser } from "@/lib/auth/user-utils";
+import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
+import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
+import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
+import {
+ useCreateService,
+ useDeactivateService,
+ useServices,
+ useUpdateService,
+} from "@/domain/services/use-cases/use-services";
+
+export type ServiceStatus = "all" | "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"],
+};
+
+export const DEFAULT_ICON_BY_TRADE: Record = {
+ HVAC: "droplets",
+ Plumbing: "droplets",
+ Electrical: "zap",
+ "General Building": "hammer",
+};
+
+export function buildServiceInput(form: ServiceForm): {
+ 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;
+ return {
+ input: {
+ ...form,
+ name,
+ completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
+ },
+ 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],
+ };
+}
+
+export function useServicesRegistryController() {
+ const { user } = useAuthContext();
+ const role = getPrimaryUserRole(user?.userRoles).toLowerCase();
+ const canManage = isAdminUser(user?.userRoles) || role === "scheduler";
+ const canDeactivate = isAdminUser(user?.userRoles);
+ const [status, setStatus] = useState("active");
+ const [search, setSearch] = useState("");
+ 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("");
+ const [deactivateTarget, setDeactivateTarget] = useState(null);
+
+ const queryStatus = status === "active" ? true : status === "inactive" ? false : undefined;
+ const servicesQuery = useServices(queryStatus);
+ const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
+ const { data: templates = [] } = useTaskTemplates();
+ const createService = useCreateService();
+ const updateService = useUpdateService();
+ const deactivateService = useDeactivateService();
+
+ const filteredServices = useMemo(() => {
+ const needle = search.trim().toLowerCase();
+ if (!needle) return servicesQuery.data ?? [];
+ return (servicesQuery.data ?? []).filter(
+ (service) =>
+ service.name.toLowerCase().includes(needle) || service.trade.toLowerCase().includes(needle),
+ );
+ }, [search, servicesQuery.data]);
+
+ 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);
+ 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: servicesQuery.data ?? [],
+ isLoading: servicesQuery.isLoading,
+ error: servicesQuery.error,
+ openCreate,
+ openEdit,
+ closeEditor,
+ updateForm,
+ toggleType,
+ save,
+ deactivateTarget,
+ setDeactivateTarget,
+ confirmDeactivate,
+ isSaving: createService.isPending || updateService.isPending,
+ isDeactivating: deactivateService.isPending,
+ };
+}
diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts
index 930881ac..5f17b9d7 100644
--- a/src/test/domain/services/api/services-api.test.ts
+++ b/src/test/domain/services/api/services-api.test.ts
@@ -67,8 +67,14 @@ describe("servicesApi", () => {
await servicesApi.update(8, input);
await servicesApi.deactivate(8);
- expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, input);
+ expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
+ ...input,
+ supportedWorkOrderTypes: [2],
+ });
expect(apiPost).toHaveBeenNthCalledWith(2, API_PATHS.services.deactivate(8));
- expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), input);
+ expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
+ ...input,
+ supportedWorkOrderTypes: [2],
+ });
});
});