diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts
index 64405aa6..303d9711 100644
--- a/src/api/api-paths.ts
+++ b/src/api/api-paths.ts
@@ -31,6 +31,7 @@ export const API_PATHS = {
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
+ completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@@ -167,6 +168,8 @@ export const API_PATHS = {
},
services: {
list: "services",
+ byId: (id: string | number) => `services/${id}`,
+ deactivate: (id: string | number) => `services/${id}/deactivate`,
},
user: {
list: "User",
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..e7145bec
--- /dev/null
+++ b/src/app/(protected)/settings/_components/services-registry-components.tsx
@@ -0,0 +1,379 @@
+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 {
+ SERVICE_WORK_ORDER_TYPES,
+ type CompletionDocTemplateOption,
+ 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: string[];
+ templates: CompletionDocTemplateOption[];
+ canChangeActiveState: boolean;
+ 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,
+ canChangeActiveState,
+ 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) && }
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type EditorFieldsProps = Pick<
+ EditorProps,
+ "mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
+>;
+
+function ServiceEditorFields({
+ mode,
+ form,
+ trades,
+ templates,
+ canChangeActiveState,
+ onUpdate,
+ onToggleType,
+}: EditorFieldsProps) {
+ return (
+ <>
+
+ 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}
+ />
+ ))}
+
+ {mode === "edit" && canChangeActiveState && (
+ onUpdate("isActive", event.target.checked)}
+ />
+ }
+ label="Active"
+ />
+ )}
+
+
+ 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..954e449e
--- /dev/null
+++ b/src/app/(protected)/settings/_components/services-registry-view.tsx
@@ -0,0 +1,279 @@
+import {
+ Alert,
+ Box,
+ Button,
+ CircularProgress,
+ Stack,
+ Tab,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Tabs,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { Plus, Search } from "lucide-react";
+import { SettingsNav } from "@/components/common/settings-nav";
+import {
+ useServicesRegistryController,
+ type ServiceStatus,
+} from "@/domain/services/use-cases/use-services-registry-controller";
+import type { Service } from "@/domain/services/types/service";
+import {
+ DeactivateServiceDialog,
+ ServiceDetailsDrawer,
+ ServiceEditorDrawer,
+ ServiceIcon,
+} from "@/app/(protected)/settings/_components/services-registry-components";
+
+function statusTabLabel(label: string, count: number) {
+ return (
+
+ {label}
+
+ {count}
+
+
+ );
+}
+
+export function ServicesRegistryView() {
+ const controller = useServicesRegistryController();
+
+ return (
+
+
+
+
+
+ Services
+
+
+ {controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
+ {controller.activeCount} active
+
+
+ {controller.canManage && (
+ }
+ >
+ Add Service
+
+ )}
+
+
+
+ controller.setSearch(event.target.value)}
+ placeholder="Search service or trade..."
+ className="flex-1"
+ slotProps={{ htmlInput: { "aria-label": "Search services" } }}
+ />
+
+
+ controller.setStatus(value)}
+ aria-label="Service status"
+ className="mb-4"
+ sx={{
+ minHeight: 36,
+ borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
+ "& .MuiTab-root": {
+ minHeight: 36,
+ px: 2,
+ py: 0,
+ fontSize: 13,
+ fontWeight: 500,
+ textTransform: "none",
+ },
+ "& .Mui-selected": { fontWeight: 600 },
+ "& .MuiTabs-indicator": { height: 2 },
+ }}
+ >
+
+
+
+
+ {Boolean(controller.error) && (
+ {controller.error?.message || "Failed to load services"}
+ )}
+ {controller.isLoading ? (
+
+
+
+ ) : controller.filteredServices.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ {!controller.isLoading && (
+ `1px solid ${theme.palette.divider}`,
+ pt: 1,
+ }}
+ >
+
+ {controller.filteredServices.length} {controller.status} service
+ {controller.filteredServices.length === 1 ? "" : "s"}
+ {controller.search.trim() ? " · filtered" : ""}
+
+
+ {controller.activeCount} active · {controller.inactiveCount} inactive
+
+
+ )}
+
+ controller.setSelected(null)}
+ onEdit={controller.openEdit}
+ onDeactivate={controller.setDeactivateTarget}
+ />
+
+ controller.setDeactivateTarget(null)}
+ onConfirm={controller.confirmDeactivate}
+ />
+
+ );
+}
+
+function ServicesTable({
+ services,
+ onSelect,
+}: {
+ services: Service[];
+ onSelect: (service: Service) => void;
+}) {
+ return (
+
+
+
+
+ Service
+ Trade
+ Completion Document
+ Status
+
+
+
+
+ {services.map((service) => (
+ onSelect(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"}
+ ›
+
+ ))}
+
+
+
+ );
+}
+
+function EmptyServices({
+ search,
+ status,
+ onAdd,
+}: {
+ search: boolean;
+ status: ServiceStatus;
+ onAdd?: () => void;
+}) {
+ return (
+
+
+
+
+
+ {search ? "No services found" : `No ${status} services configured yet`}
+
+
+ {search
+ ? "Try a different search, or add a new service."
+ : `There are no ${status} services matching this view.`}
+
+ {onAdd !== undefined && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/settings/services.tsx b/src/app/(protected)/settings/services.tsx
new file mode 100644
index 00000000..2c30fcf9
--- /dev/null
+++ b/src/app/(protected)/settings/services.tsx
@@ -0,0 +1,5 @@
+import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
+
+export default function ServicesPage() {
+ return ;
+}
diff --git a/src/components/common/settings-nav.tsx b/src/components/common/settings-nav.tsx
index dc94e074..f2f5fff5 100644
--- a/src/components/common/settings-nav.tsx
+++ b/src/components/common/settings-nav.tsx
@@ -2,6 +2,7 @@ import { NavLink } from "react-router";
import { Stack } from "@mui/material";
const SETTINGS_LINKS = [
+ { path: "/settings/services", label: "Services" },
{ path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" },
] as const;
diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts
index 58ba217c..d725d828 100644
--- a/src/domain/services/api/services-api.ts
+++ b/src/domain/services/api/services-api.ts
@@ -1,18 +1,48 @@
-import { apiGet } from "@/api/api";
+import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
-import { mapServices } from "@/domain/services/mappers/service-mapper";
-import type { Service } from "@/domain/services/types/service";
+import {
+ mapCompletionDocTemplates,
+ mapService,
+ mapServices,
+} from "@/domain/services/mappers/service-mapper";
+import type {
+ CompletionDocTemplateOption,
+ Service,
+ ServiceInput,
+} from "@/domain/services/types/service";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+const SERVICE_WORK_ORDER_TYPE_API = {
+ PM: 2,
+ Emergency: 3,
+ Reactive: 6,
+} as const;
+
const TYPE_TO_API: Record, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
};
+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, {
+ searchParams: isActive === undefined ? undefined : { isActive },
+ });
+ return mapServices(handleApiResponse(data));
+ },
+
getActive: async (): Promise => {
const data = await apiGet(API_PATHS.services.list, {
searchParams: { isActive: true },
@@ -27,4 +57,23 @@ export const servicesApi = {
});
return mapServices(handleApiResponse(data));
},
+
+ getCompletionTemplates: async (): Promise => {
+ const data = await apiGet(API_PATHS.workOrder.completionTemplates);
+ return mapCompletionDocTemplates(handleApiResponse(data));
+ },
+
+ create: async (input: ServiceInput): Promise => {
+ 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), toApiInput(input));
+ return mapService(handleApiResponse(data));
+ },
+
+ deactivate: async (id: string | number): Promise => {
+ await apiPostNoContent(API_PATHS.services.deactivate(id));
+ },
};
diff --git a/src/domain/services/mappers/service-mapper.ts b/src/domain/services/mappers/service-mapper.ts
index 4b6d6f6e..374b6994 100644
--- a/src/domain/services/mappers/service-mapper.ts
+++ b/src/domain/services/mappers/service-mapper.ts
@@ -1,62 +1,77 @@
-import type { Service } from "@/domain/services/types/service";
-import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+import type {
+ CompletionDocTemplateOption,
+ LinkedServiceTemplate,
+ Service,
+ ServiceWorkOrderType,
+} from "@/domain/services/types/service";
function asRecord(raw: unknown): Record {
return typeof raw === "object" && raw !== null ? (raw as Record) : {};
}
-function readId(item: Record): string | number {
- const value = item.id ?? item.Id;
- return typeof value === "string" || typeof value === "number" ? value : "";
-}
-
-function readString(item: Record, ...keys: string[]): string {
+function readString(record: Record, ...keys: string[]): string {
for (const key of keys) {
- const value = item[key];
+ const value = record[key];
if (typeof value === "string") return value.trim();
+ if (typeof value === "number") return String(value);
}
return "";
}
-function readBool(item: Record, ...keys: string[]): boolean {
+function readId(record: Record): string | number {
+ const value = record.id ?? record.Id;
+ return typeof value === "string" || typeof value === "number" ? value : "";
+}
+
+function readBool(record: Record, ...keys: string[]): boolean {
for (const key of keys) {
- const value = item[key];
+ const value = record[key];
if (typeof value === "boolean") return value;
}
return false;
}
-function mapWorkOrderType(value: unknown): WOType | null {
+function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
if (typeof value === "number") {
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
}
- if (typeof value === "string") {
- const normalized = value.trim().toLowerCase();
- return normalized === "pm" || normalized === "preventivemaintenance"
- ? "PM"
- : normalized === "emergency"
- ? "Emergency"
- : normalized === "reactive"
- ? "Reactive"
- : null;
- }
- return null;
+ if (typeof value !== "string") return null;
+ const normalized = value.trim().toLowerCase();
+ return normalized === "pm" || normalized === "preventivemaintenance"
+ ? "PM"
+ : normalized === "emergency"
+ ? "Emergency"
+ : normalized === "reactive"
+ ? "Reactive"
+ : null;
+}
+
+function mapTemplate(raw: unknown): LinkedServiceTemplate | null {
+ if (!raw) return null;
+ const record = asRecord(raw);
+ const id = readId(record);
+ return id === "" ? null : { id, name: readString(record, "name", "Name") };
}
export function mapService(raw: unknown): Service {
- const item = asRecord(raw);
- const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
+ const record = asRecord(raw);
+ const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
return {
- id: readId(item),
- name: readString(item, "name", "Name"),
- trade: readString(item, "trade", "Trade"),
- iconKey: readString(item, "iconKey", "IconKey"),
+ id: readId(record),
+ name: readString(record, "name", "Name"),
+ trade: readString(record, "trade", "Trade"),
+ iconKey: readString(record, "iconKey", "IconKey"),
requiresCompletionDocument: readBool(
- item,
+ record,
"requiresCompletionDocument",
"RequiresCompletionDocument",
),
- isActive: readBool(item, "isActive", "IsActive"),
+ completionDocTemplate:
+ mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
+ mapTemplate({
+ id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
+ }),
+ isActive: readBool(record, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => {
const mapped = mapWorkOrderType(value);
@@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
}
export function mapServices(raw: unknown): Service[] {
- return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
+ const items = asRecord(raw);
+ const payload = Array.isArray(raw)
+ ? raw
+ : (items.items ?? items.Items ?? items.data ?? items.Data);
+ return Array.isArray(payload)
+ ? payload.map(mapService).filter((service) => service.id !== "")
+ : [];
+}
+
+export function mapCompletionDocTemplates(raw: unknown): CompletionDocTemplateOption[] {
+ const payload = Array.isArray(raw)
+ ? raw
+ : (() => {
+ const record = asRecord(raw);
+ return record.items ?? record.Items ?? record.data ?? record.Data;
+ })();
+ return Array.isArray(payload)
+ ? payload.flatMap((item) => {
+ const record = asRecord(item);
+ const id = readId(record);
+ return id === ""
+ ? []
+ : [
+ {
+ id,
+ name: readString(record, "name", "Name"),
+ } satisfies CompletionDocTemplateOption,
+ ];
+ })
+ : [];
}
diff --git a/src/domain/services/types/service.ts b/src/domain/services/types/service.ts
index fa7a87d9..d5e56888 100644
--- a/src/domain/services/types/service.ts
+++ b/src/domain/services/types/service.ts
@@ -1,4 +1,13 @@
-import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
+export const SERVICE_WORK_ORDER_TYPES = ["PM", "Reactive", "Emergency"] as const;
+
+export type ServiceWorkOrderType = (typeof SERVICE_WORK_ORDER_TYPES)[number];
+
+export interface LinkedServiceTemplate {
+ id: string | number;
+ name: string;
+}
+
+export type CompletionDocTemplateOption = LinkedServiceTemplate;
export interface Service {
id: string | number;
@@ -6,6 +15,17 @@ export interface Service {
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
+ completionDocTemplate: LinkedServiceTemplate | null;
isActive: boolean;
- supportedWorkOrderTypes: WOType[];
+ supportedWorkOrderTypes: ServiceWorkOrderType[];
+}
+
+export interface ServiceInput {
+ name: string;
+ trade: string;
+ iconKey: string;
+ requiresCompletionDocument: boolean;
+ completionDocTemplateId: number | null;
+ supportedWorkOrderTypes: ServiceWorkOrderType[];
+ isActive?: boolean;
}
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..14e2a6ba
--- /dev/null
+++ b/src/domain/services/use-cases/use-services-registry-controller.ts
@@ -0,0 +1,283 @@
+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,
+ };
+}
diff --git a/src/domain/services/use-cases/use-services.ts b/src/domain/services/use-cases/use-services.ts
new file mode 100644
index 00000000..a82b702c
--- /dev/null
+++ b/src/domain/services/use-cases/use-services.ts
@@ -0,0 +1,62 @@
+import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { servicesApi } from "@/domain/services/api/services-api";
+import type {
+ CompletionDocTemplateOption,
+ Service,
+ ServiceInput,
+} from "@/domain/services/types/service";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+export function useServices(isActive?: boolean): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.services.list(isActive),
+ queryFn: () => servicesApi.getAll(isActive),
+ staleTime: 60 * 1000,
+ });
+}
+
+export function useCompletionDocTemplates(): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.services.completionTemplates(),
+ queryFn: servicesApi.getCompletionTemplates,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreateService() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (input: ServiceInput) => servicesApi.create(input),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
+ toast.success("Service added");
+ },
+ onError: (error: Error) => toast.error(error.message || "Failed to add service"),
+ });
+}
+
+export function useUpdateService() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, input }: { id: string | number; input: ServiceInput }) =>
+ servicesApi.update(id, input),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
+ toast.success("Service updated");
+ },
+ onError: (error: Error) => toast.error(error.message || "Failed to update service"),
+ });
+}
+
+export function useDeactivateService() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string | number) => servicesApi.deactivate(id),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
+ toast.success("Service deactivated");
+ },
+ onError: (error: Error) => toast.error(error.message || "Failed to deactivate service"),
+ });
+}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index d5234338..d5e49f9b 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -75,6 +75,8 @@ export const queryKeys = {
},
services: {
all: ["services"] as const,
+ list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
+ completionTemplates: () => [...queryKeys.services.all, "completionTemplates"] as const,
active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts
index 1fcd3f4d..035e91c0 100644
--- a/src/lib/auth/user-utils.ts
+++ b/src/lib/auth/user-utils.ts
@@ -6,13 +6,18 @@ export function getPrimaryUserRole(
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
+ return hasUserRole(userRoles, "admin");
+}
+
+export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
if (!userRoles) {
return false;
}
+ const normalizedExpectedRole = expectedRole.trim().toLowerCase();
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
- .includes("admin");
+ .includes(normalizedExpectedRole);
}
/**
diff --git a/src/test/app/(protected)/settings/services-registry-view.test.tsx b/src/test/app/(protected)/settings/services-registry-view.test.tsx
new file mode 100644
index 00000000..a696f9eb
--- /dev/null
+++ b/src/test/app/(protected)/settings/services-registry-view.test.tsx
@@ -0,0 +1,153 @@
+import { screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
+import type { Service } from "@/domain/services/types/service";
+import { renderWithProviders } from "@/test/test-utils";
+
+const useServices = vi.fn();
+const mutate = vi.fn();
+
+vi.mock("@/providers/auth-context", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useAuthContext: () => ({ user: { id: "u1", userRoles: "admin,scheduler" } }),
+ };
+});
+
+vi.mock("@/domain/services/use-cases/use-services", () => ({
+ useServices: (...args: unknown[]) => useServices(...args),
+ useCompletionDocTemplates: () => ({ data: [] }),
+ useCreateService: () => ({ mutate, isPending: false }),
+ useUpdateService: () => ({ mutate, isPending: false }),
+ useDeactivateService: () => ({ mutate, isPending: false }),
+}));
+
+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 = {
+ id: 1,
+ name: "HVAC Repair",
+ trade: "HVAC",
+ iconKey: "zap",
+ requiresCompletionDocument: false,
+ completionDocTemplate: null,
+ isActive: true,
+ supportedWorkOrderTypes: ["PM", "Reactive"],
+};
+
+const activeDrain: Service = {
+ ...activeHvac,
+ id: 2,
+ name: "Drain Cleaning",
+ trade: "Plumbing",
+};
+
+const inactiveFire: Service = {
+ ...activeHvac,
+ id: 3,
+ name: "Fire Suppression",
+ trade: "Fire Protection",
+ isActive: false,
+};
+
+function setup(services: Service[] = [activeHvac, activeDrain, inactiveFire]) {
+ useServices.mockReturnValue({
+ data: services,
+ isLoading: false,
+ error: null,
+ });
+ return renderWithProviders(, { route: "/settings/services" });
+}
+
+describe("ServicesRegistryView", () => {
+ it("renders Active and Inactive tabs with live counts from the unfiltered list", () => {
+ setup();
+
+ expect(useServices).toHaveBeenCalledWith();
+ expect(screen.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByRole("tab", { name: "Inactive (1)" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ expect(screen.getByText("3 services · 2 active")).toBeInTheDocument();
+ expect(screen.getByRole("row", { name: /HVAC Repair/ })).toBeInTheDocument();
+ expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
+ });
+
+ it("switches to the Inactive tab and shows only inactive services", async () => {
+ setup();
+
+ await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
+
+ expect(screen.getByRole("row", { name: /Fire Suppression/ })).toBeInTheDocument();
+ expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
+ expect(screen.getByText("1 inactive service")).toBeInTheDocument();
+ expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
+ });
+
+ it("applies the search within the selected tab and reports the filtered footer count", async () => {
+ setup();
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "drain");
+
+ expect(screen.getByRole("row", { name: /Drain Cleaning/ })).toBeInTheDocument();
+ expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
+ expect(screen.getByText("1 active service · filtered")).toBeInTheDocument();
+ expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
+ });
+
+ it("shows an icon, message, and try-a-different-search hint when a tab and search combination is empty", async () => {
+ setup();
+
+ await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
+ await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "hvac");
+
+ expect(screen.getByTestId("services-empty-icon")).toBeInTheDocument();
+ expect(screen.getByText("No services found")).toBeInTheDocument();
+ expect(screen.getByText("Try a different search, or add a new service.")).toBeInTheDocument();
+ 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();
+
+ const footer = screen.getByText("2 active services").closest("div");
+ expect(footer).not.toBeNull();
+ expect(within(footer as HTMLElement).getByText("2 active · 1 inactive")).toBeInTheDocument();
+ expect(screen.queryByText(/filtered/)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts
index ab558d03..be46e93b 100644
--- a/src/test/domain/services/api/services-api.test.ts
+++ b/src/test/domain/services/api/services-api.test.ts
@@ -1,10 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
+import type { ServiceInput } from "@/domain/services/types/service";
const apiGet = vi.fn();
+const apiPost = vi.fn();
+const apiPostNoContent = vi.fn();
+const apiPut = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
+ apiPost: (...args: unknown[]) => apiPost(...args),
+ apiPostNoContent: (...args: unknown[]) => apiPostNoContent(...args),
+ apiPut: (...args: unknown[]) => apiPut(...args),
}));
import { servicesApi } from "@/domain/services/api/services-api";
@@ -12,6 +19,38 @@ import { servicesApi } from "@/domain/services/api/services-api";
describe("servicesApi", () => {
beforeEach(() => {
apiGet.mockReset();
+ apiPost.mockReset();
+ apiPostNoContent.mockReset();
+ apiPut.mockReset();
+ });
+
+ it("loads active services and maps the linked completion template", async () => {
+ apiGet.mockResolvedValue({
+ items: [
+ {
+ Id: 7,
+ Name: "HVAC inspection",
+ Trade: "HVAC",
+ IconKey: "droplets",
+ RequiresCompletionDocument: true,
+ CompletionDocTemplate: { Id: 4, Name: "HVAC checklist" },
+ IsActive: true,
+ SupportedWorkOrderTypes: [2, 6],
+ },
+ ],
+ });
+
+ const services = await servicesApi.getAll(true);
+
+ expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
+ searchParams: { isActive: true },
+ });
+ expect(services[0]).toMatchObject({
+ id: 7,
+ name: "HVAC inspection",
+ completionDocTemplate: { id: 4, name: "HVAC checklist" },
+ supportedWorkOrderTypes: ["PM", "Reactive"],
+ });
});
it("loads active services filtered by the selected work-order type", async () => {
@@ -45,4 +84,49 @@ describe("servicesApi", () => {
expect(apiGet).not.toHaveBeenCalled();
expect(services).toEqual([]);
});
+
+ it("creates, updates, and deactivates through the registry routes", async () => {
+ const input: ServiceInput = {
+ name: "Boiler inspection",
+ trade: "HVAC",
+ iconKey: "droplets",
+ requiresCompletionDocument: false,
+ completionDocTemplateId: null,
+ supportedWorkOrderTypes: ["PM"],
+ };
+
+ apiPost.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
+ apiPut.mockResolvedValue({
+ id: 8,
+ ...input,
+ completionDocTemplateId: 12,
+ isActive: true,
+ supportedWorkOrderTypes: [2],
+ });
+ apiPostNoContent.mockResolvedValue(undefined);
+
+ await servicesApi.create(input);
+ const updated = await servicesApi.update(8, input);
+ await servicesApi.deactivate(8);
+
+ expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
+ ...input,
+ supportedWorkOrderTypes: [2],
+ });
+ expect(apiPostNoContent).toHaveBeenCalledWith(API_PATHS.services.deactivate(8));
+ expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
+ ...input,
+ supportedWorkOrderTypes: [2],
+ });
+ expect(updated.completionDocTemplate).toEqual({ id: 12, name: "" });
+ });
+
+ it("loads active Completion Document Template options from the completion route", async () => {
+ apiGet.mockResolvedValue([{ Id: 4, Name: "HVAC checklist", IsActive: true }]);
+
+ const templates = await servicesApi.getCompletionTemplates();
+
+ expect(apiGet).toHaveBeenCalledWith(API_PATHS.workOrder.completionTemplates);
+ expect(templates).toEqual([{ id: 4, name: "HVAC checklist" }]);
+ });
});
diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts
index ac185d49..ea96370c 100644
--- a/src/test/lib/auth/user-utils.test.ts
+++ b/src/test/lib/auth/user-utils.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
+import { canViewAllDispatchersOnDashboard, hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => {
@@ -14,6 +14,13 @@ describe("isAdminUser", () => {
});
});
+describe("hasUserRole", () => {
+ it("finds a role regardless of its position", () => {
+ expect(hasUserRole("Dispatcher,Scheduler", "Scheduler")).toBe(true);
+ expect(hasUserRole("Dispatcher", "Scheduler")).toBe(false);
+ });
+});
+
describe("canViewAllDispatchersOnDashboard", () => {
it("grants the dashboard picker to Admin and Scheduler", () => {
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);