mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 16:32:06 +00:00
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error
This commit is contained in:
commit
de7066d31b
15 changed files with 1415 additions and 39 deletions
|
|
@ -31,6 +31,7 @@ export const API_PATHS = {
|
||||||
boardSearch: "workorders/board/search",
|
boardSearch: "workorders/board/search",
|
||||||
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
||||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||||
|
completionTemplates: "workorders/completion-templates",
|
||||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||||
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
||||||
`workorders/${workOrderId}/comments/${commentId}`,
|
`workorders/${workOrderId}/comments/${commentId}`,
|
||||||
|
|
@ -167,6 +168,8 @@ export const API_PATHS = {
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
list: "services",
|
list: "services",
|
||||||
|
byId: (id: string | number) => `services/${id}`,
|
||||||
|
deactivate: (id: string | number) => `services/${id}/deactivate`,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
list: "User",
|
list: "User",
|
||||||
|
|
|
||||||
|
|
@ -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 <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: string[];
|
||||||
|
templates: CompletionDocTemplateOption[];
|
||||||
|
canChangeActiveState: boolean;
|
||||||
|
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,
|
||||||
|
canChangeActiveState,
|
||||||
|
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} />}
|
||||||
|
<ServiceEditorFields
|
||||||
|
mode={mode}
|
||||||
|
form={form}
|
||||||
|
trades={trades}
|
||||||
|
templates={templates}
|
||||||
|
canChangeActiveState={canChangeActiveState}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onToggleType={onToggleType}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorFieldsProps = Pick<
|
||||||
|
EditorProps,
|
||||||
|
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function ServiceEditorFields({
|
||||||
|
mode,
|
||||||
|
form,
|
||||||
|
trades,
|
||||||
|
templates,
|
||||||
|
canChangeActiveState,
|
||||||
|
onUpdate,
|
||||||
|
onToggleType,
|
||||||
|
}: EditorFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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={trade} value={trade}>
|
||||||
|
{trade}
|
||||||
|
</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>
|
||||||
|
{mode === "edit" && canChangeActiveState && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={form.isActive ?? true}
|
||||||
|
onChange={(event) => onUpdate("isActive", event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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's own content on the Completion Document Templates page.
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Box component="span">{label}</Box>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
|
||||||
|
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesRegistryView() {
|
||||||
|
const controller = useServicesRegistryController();
|
||||||
|
|
||||||
|
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"} ·{" "}
|
||||||
|
{controller.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 service or trade..."
|
||||||
|
className="flex-1"
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={controller.status}
|
||||||
|
onChange={(_event, value: ServiceStatus) => 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 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="active"
|
||||||
|
label={statusTabLabel("Active", controller.activeCount)}
|
||||||
|
aria-label={`Active (${controller.activeCount})`}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="inactive"
|
||||||
|
label={statusTabLabel("Inactive", controller.inactiveCount)}
|
||||||
|
aria-label={`Inactive (${controller.inactiveCount})`}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{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)}
|
||||||
|
status={controller.status}
|
||||||
|
onAdd={controller.canManage ? controller.openCreate : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!controller.isLoading && (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
className="mt-2"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
pt: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{controller.filteredServices.length} {controller.status} service
|
||||||
|
{controller.filteredServices.length === 1 ? "" : "s"}
|
||||||
|
{controller.search.trim() ? " · filtered" : ""}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{controller.activeCount} active · {controller.inactiveCount} inactive
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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}
|
||||||
|
canChangeActiveState={controller.canDeactivate}
|
||||||
|
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 ServicesTable({
|
||||||
|
services,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
services: Service[];
|
||||||
|
onSelect: (service: Service) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{services.map((service) => (
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
key={String(service.id)}
|
||||||
|
onClick={() => onSelect(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyServices({
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
search: boolean;
|
||||||
|
status: ServiceStatus;
|
||||||
|
onAdd?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
|
||||||
|
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
|
||||||
|
<Search size={22} aria-hidden="true" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" className="font-semibold">
|
||||||
|
{search ? "No services found" : `No ${status} services configured yet`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
|
{search
|
||||||
|
? "Try a different search, or add a new service."
|
||||||
|
: `There are no ${status} services matching this view.`}
|
||||||
|
</Typography>
|
||||||
|
{onAdd !== undefined && (
|
||||||
|
<Button variant="outlined" className="mt-4" onClick={onAdd}>
|
||||||
|
Add Service
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/(protected)/settings/services.tsx
Normal file
5
src/app/(protected)/settings/services.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
return <ServicesRegistryView />;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { NavLink } from "react-router";
|
||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
|
|
||||||
const SETTINGS_LINKS = [
|
const SETTINGS_LINKS = [
|
||||||
|
{ path: "/settings/services", label: "Services" },
|
||||||
{ path: "/settings/dropdowns", label: "Dropdown Options" },
|
{ path: "/settings/dropdowns", label: "Dropdown Options" },
|
||||||
{ path: "/settings/task-templates", label: "Task Templates" },
|
{ path: "/settings/task-templates", label: "Task Templates" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -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 { API_PATHS } from "@/api/api-paths";
|
||||||
import { handleApiResponse } from "@/api/handle-api-response";
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import { mapServices } from "@/domain/services/mappers/service-mapper";
|
import {
|
||||||
import type { Service } from "@/domain/services/types/service";
|
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 { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
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<Exclude<WOType, "Overdue">, number> = {
|
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
|
||||||
PM: WORK_ORDER_TYPE_API.PM,
|
PM: WORK_ORDER_TYPE_API.PM,
|
||||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
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 = {
|
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));
|
||||||
|
},
|
||||||
|
|
||||||
getActive: async (): Promise<Service[]> => {
|
getActive: async (): Promise<Service[]> => {
|
||||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||||
searchParams: { isActive: true },
|
searchParams: { isActive: true },
|
||||||
|
|
@ -27,4 +57,23 @@ export const servicesApi = {
|
||||||
});
|
});
|
||||||
return mapServices(handleApiResponse(data));
|
return mapServices(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCompletionTemplates: async (): Promise<CompletionDocTemplateOption[]> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates);
|
||||||
|
return mapCompletionDocTemplates(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (input: ServiceInput): Promise<Service> => {
|
||||||
|
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), toApiInput(input));
|
||||||
|
return mapService(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivate: async (id: string | number): Promise<void> => {
|
||||||
|
await apiPostNoContent(API_PATHS.services.deactivate(id));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,77 @@
|
||||||
import type { Service } from "@/domain/services/types/service";
|
import type {
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
CompletionDocTemplateOption,
|
||||||
|
LinkedServiceTemplate,
|
||||||
|
Service,
|
||||||
|
ServiceWorkOrderType,
|
||||||
|
} from "@/domain/services/types/service";
|
||||||
|
|
||||||
function asRecord(raw: unknown): Record<string, unknown> {
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readId(item: Record<string, unknown>): string | number {
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
const value = item.id ?? item.Id;
|
|
||||||
return typeof value === "string" || typeof value === "number" ? value : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readString(item: Record<string, unknown>, ...keys: string[]): string {
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = item[key];
|
const value = record[key];
|
||||||
if (typeof value === "string") return value.trim();
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (typeof value === "number") return String(value);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
|
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) {
|
for (const key of keys) {
|
||||||
const value = item[key];
|
const value = record[key];
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapWorkOrderType(value: unknown): WOType | null {
|
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
|
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value !== "string") return null;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
return normalized === "pm" || normalized === "preventivemaintenance"
|
return normalized === "pm" || normalized === "preventivemaintenance"
|
||||||
? "PM"
|
? "PM"
|
||||||
: normalized === "emergency"
|
: normalized === "emergency"
|
||||||
? "Emergency"
|
? "Emergency"
|
||||||
: normalized === "reactive"
|
: normalized === "reactive"
|
||||||
? "Reactive"
|
? "Reactive"
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
return 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 {
|
export function mapService(raw: unknown): Service {
|
||||||
const item = asRecord(raw);
|
const record = asRecord(raw);
|
||||||
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
|
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(record),
|
||||||
name: readString(item, "name", "Name"),
|
name: readString(record, "name", "Name"),
|
||||||
trade: readString(item, "trade", "Trade"),
|
trade: readString(record, "trade", "Trade"),
|
||||||
iconKey: readString(item, "iconKey", "IconKey"),
|
iconKey: readString(record, "iconKey", "IconKey"),
|
||||||
requiresCompletionDocument: readBool(
|
requiresCompletionDocument: readBool(
|
||||||
item,
|
record,
|
||||||
"requiresCompletionDocument",
|
"requiresCompletionDocument",
|
||||||
"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)
|
supportedWorkOrderTypes: Array.isArray(types)
|
||||||
? types.flatMap((value) => {
|
? types.flatMap((value) => {
|
||||||
const mapped = mapWorkOrderType(value);
|
const mapped = mapWorkOrderType(value);
|
||||||
|
|
@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapServices(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,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export interface Service {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
|
@ -6,6 +15,17 @@ export interface Service {
|
||||||
trade: string;
|
trade: string;
|
||||||
iconKey: string;
|
iconKey: string;
|
||||||
requiresCompletionDocument: boolean;
|
requiresCompletionDocument: boolean;
|
||||||
|
completionDocTemplate: LinkedServiceTemplate | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
supportedWorkOrderTypes: WOType[];
|
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInput {
|
||||||
|
name: string;
|
||||||
|
trade: string;
|
||||||
|
iconKey: string;
|
||||||
|
requiresCompletionDocument: boolean;
|
||||||
|
completionDocTemplateId: number | null;
|
||||||
|
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<ServiceInput, "completionDocTemplateId"> & {
|
||||||
|
completionDocTemplateId: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_SERVICE_FORM: ServiceForm = {
|
||||||
|
name: "",
|
||||||
|
trade: "",
|
||||||
|
iconKey: "",
|
||||||
|
requiresCompletionDocument: false,
|
||||||
|
completionDocTemplateId: null,
|
||||||
|
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
|
||||||
|
// the legacy DropdownOptions seed. A trade with no explicit default falls back
|
||||||
|
// to "wrench" where this map is read.
|
||||||
|
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
|
||||||
|
HVAC: "droplets",
|
||||||
|
"Plumbing & Water Systems": "droplets",
|
||||||
|
Electrical: "zap",
|
||||||
|
"General Building & Handyman": "hammer",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildServiceInput(
|
||||||
|
form: ServiceForm,
|
||||||
|
includeActiveState = false,
|
||||||
|
): {
|
||||||
|
input: ServiceInput | null;
|
||||||
|
error: string;
|
||||||
|
} {
|
||||||
|
const name = form.name.trim();
|
||||||
|
if (!name || !form.trade || !form.iconKey) {
|
||||||
|
return { input: null, error: "Service Name, Trade, and Icon are required." };
|
||||||
|
}
|
||||||
|
if (!form.supportedWorkOrderTypes.length) {
|
||||||
|
return { input: null, error: "Select at least one Work Order Type." };
|
||||||
|
}
|
||||||
|
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
|
||||||
|
return {
|
||||||
|
input: null,
|
||||||
|
error: "Select a completion document template when the document is required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const templateId = form.completionDocTemplateId;
|
||||||
|
const { isActive, ...editableFields } = form;
|
||||||
|
return {
|
||||||
|
input: {
|
||||||
|
...editableFields,
|
||||||
|
name,
|
||||||
|
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
|
||||||
|
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): ServiceForm {
|
||||||
|
return {
|
||||||
|
...EMPTY_SERVICE_FORM,
|
||||||
|
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromService(service: Service): ServiceForm {
|
||||||
|
return {
|
||||||
|
name: service.name,
|
||||||
|
trade: service.trade,
|
||||||
|
iconKey: service.iconKey,
|
||||||
|
requiresCompletionDocument: service.requiresCompletionDocument,
|
||||||
|
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
|
||||||
|
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
|
||||||
|
isActive: service.isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<typeof useCreateService>,
|
||||||
|
updateService: ReturnType<typeof useUpdateService>,
|
||||||
|
) {
|
||||||
|
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("");
|
||||||
|
|
||||||
|
// Keep the trade stored on the service being edited selectable even if it was
|
||||||
|
// later deactivated in the canonical Trade catalog, so editing never silently
|
||||||
|
// blanks the field and forces an unrelated trade change.
|
||||||
|
const trades = useMemo(() => {
|
||||||
|
if (form.trade && !canonicalTrades.includes(form.trade)) {
|
||||||
|
return [form.trade, ...canonicalTrades];
|
||||||
|
}
|
||||||
|
return canonicalTrades;
|
||||||
|
}, [canonicalTrades, form.trade]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!iconTouched && form.trade) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [form.trade, iconTouched]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setSelected(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setIconTouched(false);
|
||||||
|
setFormError("");
|
||||||
|
setEditorMode("add");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (service: Service) => {
|
||||||
|
setSelected(service);
|
||||||
|
setForm(formFromService(service));
|
||||||
|
setIconTouched(true);
|
||||||
|
setFormError("");
|
||||||
|
setEditorMode("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
|
||||||
|
if (key === "trade") setIconTouched(false);
|
||||||
|
setFormError("");
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleType = (type: ServiceWorkOrderType) => {
|
||||||
|
setForm((current) => {
|
||||||
|
const supported = current.supportedWorkOrderTypes.includes(type)
|
||||||
|
? current.supportedWorkOrderTypes.filter((item) => item !== type)
|
||||||
|
: [...current.supportedWorkOrderTypes, type];
|
||||||
|
return { ...current, supportedWorkOrderTypes: supported };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const validation = buildServiceInput(form, canDeactivate);
|
||||||
|
if (!validation.input) {
|
||||||
|
setFormError(validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = validation.input;
|
||||||
|
if (editorMode === "add") {
|
||||||
|
createService.mutate(input, {
|
||||||
|
onSuccess: (created) => {
|
||||||
|
setEditorMode(null);
|
||||||
|
setSelected(created);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (editorMode === "edit" && selected) {
|
||||||
|
updateService.mutate(
|
||||||
|
{ id: selected.id, input },
|
||||||
|
{
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
setEditorMode(null);
|
||||||
|
setSelected(updated);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ServiceStatus>("active");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
|
||||||
|
|
||||||
|
const servicesQuery = useServices();
|
||||||
|
// The backend rejects any Trade outside the canonical TradeCatalog (400
|
||||||
|
// TradeInvalid), so the picker is sourced from the server-owned canonical
|
||||||
|
// Trade reference exposed on Vendor/facets — the same list the vendor roster
|
||||||
|
// form uses — never the editable DropdownOptions "Trade" category, whose
|
||||||
|
// legacy seed values ("Plumbing", "General", ...) would fail on save.
|
||||||
|
const facetsQuery = useVendorFacets();
|
||||||
|
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
|
||||||
|
const { data: templates = [] } = useCompletionDocTemplates();
|
||||||
|
const createService = useCreateService();
|
||||||
|
const updateService = useUpdateService();
|
||||||
|
const deactivateService = useDeactivateService();
|
||||||
|
|
||||||
|
const 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
src/domain/services/use-cases/use-services.ts
Normal file
62
src/domain/services/use-cases/use-services.ts
Normal file
|
|
@ -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<Service[], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.services.list(isActive),
|
||||||
|
queryFn: () => servicesApi.getAll(isActive),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompletionDocTemplates(): UseQueryResult<CompletionDocTemplateOption[], Error> {
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,8 @@ export const queryKeys = {
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
all: ["services"] as const,
|
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,
|
active: () => [...queryKeys.services.all, "active"] as const,
|
||||||
byWorkOrderType: (workOrderType: string) =>
|
byWorkOrderType: (workOrderType: string) =>
|
||||||
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
|
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,18 @@ export function getPrimaryUserRole(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAdminUser(userRoles: string | null | undefined): boolean {
|
export function isAdminUser(userRoles: string | null | undefined): boolean {
|
||||||
|
return hasUserRole(userRoles, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
|
||||||
if (!userRoles) {
|
if (!userRoles) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const normalizedExpectedRole = expectedRole.trim().toLowerCase();
|
||||||
return userRoles
|
return userRoles
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((role) => role.trim().toLowerCase())
|
.map((role) => role.trim().toLowerCase())
|
||||||
.includes("admin");
|
.includes(normalizedExpectedRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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<typeof import("@/providers/auth-context")>();
|
||||||
|
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(<ServicesRegistryView />, { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
import type { ServiceInput } from "@/domain/services/types/service";
|
||||||
|
|
||||||
const apiGet = vi.fn();
|
const apiGet = vi.fn();
|
||||||
|
const apiPost = vi.fn();
|
||||||
|
const apiPostNoContent = vi.fn();
|
||||||
|
const apiPut = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
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";
|
import { servicesApi } from "@/domain/services/api/services-api";
|
||||||
|
|
@ -12,6 +19,38 @@ import { servicesApi } from "@/domain/services/api/services-api";
|
||||||
describe("servicesApi", () => {
|
describe("servicesApi", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiGet.mockReset();
|
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 () => {
|
it("loads active services filtered by the selected work-order type", async () => {
|
||||||
|
|
@ -45,4 +84,49 @@ describe("servicesApi", () => {
|
||||||
expect(apiGet).not.toHaveBeenCalled();
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
expect(services).toEqual([]);
|
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" }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("isAdminUser", () => {
|
||||||
it("returns true when Admin appears in roles", () => {
|
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", () => {
|
describe("canViewAllDispatchersOnDashboard", () => {
|
||||||
it("grants the dashboard picker to Admin and Scheduler", () => {
|
it("grants the dashboard picker to Admin and Scheduler", () => {
|
||||||
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);
|
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue