refactor(services): split registry management view

This commit is contained in:
Codex Review Integration 2026-09-16 19:05:02 -03:00
parent c9ab1f3598
commit 0b63d882ab
6 changed files with 793 additions and 612 deletions

View file

@ -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 <Icon size={size} aria-hidden="true" />;
}
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 (
<Drawer anchor="right" open={open} onClose={onClose}>
{service !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{service.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{service.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{service.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => onEdit(service)}>
Edit
</Button>
)}
{canDeactivate && service.isActive && (
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
);
}
type EditorProps = {
mode: "add" | "edit" | null;
form: ServiceForm;
formError: string;
trades: DropdownOption[];
templates: TaskTemplate[];
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onUpdate: <K extends keyof ServiceForm>(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 (
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{mode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <AlertMessage message={formError} />}
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => onUpdate("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => onUpdate("trade", event.target.value)}
>
{trades.map((trade) => (
<MenuItem key={String(trade.id)} value={trade.value}>
{trade.value}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => onUpdate("iconKey", event.target.value)}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => onToggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
onUpdate("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={
templates.find((template) => 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) => (
<TextField {...params} label="Linked Template" placeholder="Select a template" />
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
);
}
function AlertMessage({ message }: { message: string }) {
return (
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{message}
</Box>
);
}
type DeactivateProps = {
service: Service | null;
open: boolean;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function DeactivateServiceDialog({
service,
open,
isPending,
onClose,
onConfirm,
}: DeactivateProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{service?.name} will no longer be selectable for new work orders. Existing work orders
will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
Deactivate
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -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 (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{activeCount} active
</Typography>
</Box>
{controller.canManage && (
<Button
variant="contained"
onClick={controller.openCreate}
startIcon={<Plus size={16} />}
>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search services..."
className="flex-1"
aria-label="Search services"
/>
<FormControl size="small" className="min-w-[150px]">
<InputLabel id="service-status-label">Status</InputLabel>
<Select
labelId="service-status-label"
label="Status"
value={controller.status}
onChange={(event) => controller.setStatus(event.target.value as ServiceStatus)}
>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
<MenuItem value="all">All services</MenuItem>
</Select>
</FormControl>
</Stack>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
{controller.isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : controller.filteredServices.length === 0 ? (
<EmptyServices
search={Boolean(controller.search)}
onAdd={controller.canManage ? controller.openCreate : undefined}
/>
) : (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{controller.filteredServices.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => controller.setSelected(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell align="right">›</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
canManage={controller.canManage}
canDeactivate={controller.canDeactivate}
onClose={() => controller.setSelected(null)}
onEdit={controller.openEdit}
onDeactivate={controller.setDeactivateTarget}
/>
<ServiceEditorDrawer
mode={controller.editorMode}
form={controller.form}
formError={controller.formError}
trades={controller.trades}
templates={controller.templates}
isSaving={controller.isSaving}
onClose={controller.closeEditor}
onSave={controller.save}
onUpdate={controller.updateForm}
onToggleType={controller.toggleType}
/>
<DeactivateServiceDialog
service={controller.deactivateTarget}
open={controller.deactivateTarget !== null}
isPending={controller.isDeactivating}
onClose={() => controller.setDeactivateTarget(null)}
onConfirm={controller.confirmDeactivate}
/>
</Box>
);
}
function EmptyServices({ search, onAdd }: { search: boolean; onAdd?: () => void }) {
return (
<Box className="rounded-lg border border-dashed border-border bg-background py-16 text-center">
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : "No services configured yet"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: "Add a service to make it available for work orders."}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
}

View file

@ -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<ServiceInput, "completionDocTemplateId"> & {
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<string, string> = {
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 <Icon size={size} aria-hidden="true" />;
}
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<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 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 = <K extends keyof ServiceForm>(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 (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{services.length} service{services.length === 1 ? "" : "s"} · {activeCount} active
</Typography>
</Box>
{canManage && (
<Button variant="contained" onClick={openCreate} startIcon={<Plus size={16} />}>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search services..."
className="flex-1"
aria-label="Search services"
/>
<FormControl size="small" className="min-w-[150px]">
<InputLabel id="service-status-label">Status</InputLabel>
<Select
labelId="service-status-label"
label="Status"
value={status}
onChange={(event) => setStatus(event.target.value as ServiceStatus)}
>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
<MenuItem value="all">All services</MenuItem>
</Select>
</FormControl>
</Stack>
{Boolean(error) && (
<Alert severity="error">{error?.message || "Failed to load services"}</Alert>
)}
{isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : filteredServices.length === 0 ? (
<PaperEmpty search={Boolean(search)} onAdd={canManage ? openCreate : undefined} />
) : (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{filteredServices.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => setSelected(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>
<Chip
size="small"
label={service.isActive ? "Active" : "Inactive"}
color={service.isActive ? "success" : "default"}
variant={service.isActive ? "outlined" : "filled"}
/>
</TableCell>
<TableCell align="right">
<ChevronRight size={18} className="text-muted-foreground" aria-hidden="true" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
<Drawer
anchor="right"
open={selected !== null && editorMode === null}
onClose={() => setSelected(null)}
>
{selected !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{selected.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={selected.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={selected.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{selected.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{selected.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{selected.requiresCompletionDocument
? selected.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => openEdit(selected)}>
Edit
</Button>
)}
{canDeactivate && selected.isActive && (
<Button
color="warning"
variant="contained"
onClick={() => setDeactivateTarget(selected)}
>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
<Drawer anchor="right" open={editorMode !== null} onClose={closeEditor}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{editorMode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <Alert severity="error">{formError}</Alert>}
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => updateForm("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => {
setIconTouched(false);
updateForm("trade", event.target.value);
}}
>
{trades.map((trade) => (
<MenuItem key={String(trade.id)} value={trade.value}>
{trade.value}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => {
setIconTouched(true);
updateForm("iconKey", event.target.value);
}}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => toggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
updateForm("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) updateForm("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={
templates.find((template) => 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) => (
<TextField
{...params}
label="Linked Template"
placeholder="Select a template"
/>
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={closeEditor}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={isSaving}>
{isSaving ? "Saving..." : editorMode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
<Dialog open={deactivateTarget !== null} onClose={() => setDeactivateTarget(null)}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{deactivateTarget?.name} will no longer be selectable for new work orders. Existing work
orders will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeactivateTarget(null)}>Cancel</Button>
<Button
color="warning"
variant="contained"
onClick={confirmDeactivate}
disabled={deactivateService.isPending}
>
Deactivate
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
function PaperEmpty({ search, onAdd }: { search: boolean; onAdd?: () => void }) {
return (
<Box className="rounded-lg border border-dashed border-border bg-background py-16 text-center">
<Circle size={24} className="mx-auto mb-2 text-muted-foreground" aria-hidden="true" />
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : "No services configured yet"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: "Add a service to make it available for work orders."}
</Typography>
{Boolean(onAdd) && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
return <ServicesRegistryView />;
}

View file

@ -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<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
@ -13,12 +28,12 @@ export const servicesApi = {
},
create: async (input: ServiceInput): Promise<Service> => {
const data = await apiPost<unknown>(API_PATHS.services.list, input);
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
const data = await apiPut<unknown>(API_PATHS.services.byId(id), input);
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},

View file

@ -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<ServiceInput, "completionDocTemplateId"> & {
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<string, string> = {
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<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 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 = <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);
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,
};
}

View file

@ -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],
});
});
});