mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
feat(services): add registry management page
This commit is contained in:
parent
07734d8361
commit
c9ab1f3598
9 changed files with 890 additions and 0 deletions
|
|
@ -155,6 +155,11 @@ export const API_PATHS = {
|
|||
update: "DropdownOptions/Update",
|
||||
delete: "DropdownOptions",
|
||||
},
|
||||
services: {
|
||||
list: "services",
|
||||
byId: (id: string | number) => `services/${id}`,
|
||||
deactivate: (id: string | number) => `services/${id}/deactivate`,
|
||||
},
|
||||
user: {
|
||||
list: "User",
|
||||
},
|
||||
|
|
|
|||
611
src/app/(protected)/settings/services.tsx
Normal file
611
src/app/(protected)/settings/services.tsx
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
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],
|
||||
};
|
||||
}
|
||||
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
28
src/domain/services/api/services-api.ts
Normal file
28
src/domain/services/api/services-api.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
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";
|
||||
|
||||
export const servicesApi = {
|
||||
getAll: async (isActive?: boolean): Promise<Service[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||
searchParams: isActive === undefined ? undefined : { isActive },
|
||||
});
|
||||
return mapServices(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (input: ServiceInput): Promise<Service> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.services.list, 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);
|
||||
return mapService(handleApiResponse(data));
|
||||
},
|
||||
|
||||
deactivate: async (id: string | number): Promise<void> => {
|
||||
await apiPost(API_PATHS.services.deactivate(id));
|
||||
},
|
||||
};
|
||||
89
src/domain/services/mappers/service-mapper.ts
Normal file
89
src/domain/services/mappers/service-mapper.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type {
|
||||
LinkedServiceTemplate,
|
||||
Service,
|
||||
ServiceWorkOrderType,
|
||||
} from "@/domain/services/types/service";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number") return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
return typeof value === "string" || typeof value === "number" ? value : "";
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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") 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 record = asRecord(raw);
|
||||
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
|
||||
return {
|
||||
id: readId(record),
|
||||
name: readString(record, "name", "Name"),
|
||||
trade: readString(record, "trade", "Trade"),
|
||||
iconKey: readString(record, "iconKey", "IconKey"),
|
||||
requiresCompletionDocument: readBool(
|
||||
record,
|
||||
"requiresCompletionDocument",
|
||||
"RequiresCompletionDocument",
|
||||
),
|
||||
completionDocTemplate: mapTemplate(
|
||||
record.completionDocTemplate ?? record.CompletionDocTemplate,
|
||||
),
|
||||
isActive: readBool(record, "isActive", "IsActive"),
|
||||
supportedWorkOrderTypes: Array.isArray(types)
|
||||
? types.flatMap((value) => {
|
||||
const mapped = mapWorkOrderType(value);
|
||||
return mapped ? [mapped] : [];
|
||||
})
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapServices(raw: unknown): Service[] {
|
||||
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 !== "")
|
||||
: [];
|
||||
}
|
||||
28
src/domain/services/types/service.ts
Normal file
28
src/domain/services/types/service.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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 interface Service {
|
||||
id: string | number;
|
||||
name: string;
|
||||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplate: LinkedServiceTemplate | null;
|
||||
isActive: boolean;
|
||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||
}
|
||||
|
||||
export interface ServiceInput {
|
||||
name: string;
|
||||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplateId: number | null;
|
||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||
}
|
||||
50
src/domain/services/use-cases/use-services.ts
Normal file
50
src/domain/services/use-cases/use-services.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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 { Service, ServiceInput } from "@/domain/services/types/service";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useServices(isActive?: boolean): UseQueryResult<Service[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.services.list(isActive),
|
||||
queryFn: () => servicesApi.getAll(isActive),
|
||||
staleTime: 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"),
|
||||
});
|
||||
}
|
||||
|
|
@ -56,6 +56,10 @@ export const queryKeys = {
|
|||
byCategory: (category: string, parent?: string) =>
|
||||
[...queryKeys.dropdownOptions.all, "byCategory", category, parent ?? ""] as const,
|
||||
},
|
||||
services: {
|
||||
all: ["services"] as const,
|
||||
list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
|
||||
},
|
||||
locations: {
|
||||
all: ["locations"] as const,
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
|
|
|
|||
74
src/test/domain/services/api/services-api.test.ts
Normal file
74
src/test/domain/services/api/services-api.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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 apiPut = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||
}));
|
||||
|
||||
import { servicesApi } from "@/domain/services/api/services-api";
|
||||
|
||||
describe("servicesApi", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPost.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("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, isActive: true, supportedWorkOrderTypes: [2] });
|
||||
|
||||
await servicesApi.create(input);
|
||||
await servicesApi.update(8, input);
|
||||
await servicesApi.deactivate(8);
|
||||
|
||||
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, input);
|
||||
expect(apiPost).toHaveBeenNthCalledWith(2, API_PATHS.services.deactivate(8));
|
||||
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), input);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue