mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 22:52:06 +00:00
refactor(services): split registry management view
This commit is contained in:
parent
c9ab1f3598
commit
0b63d882ab
6 changed files with 793 additions and 612 deletions
|
|
@ -0,0 +1,342 @@
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
Drawer,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
FormHelperText,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
|
||||||
|
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
|
||||||
|
import type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
|
||||||
|
import {
|
||||||
|
SERVICE_WORK_ORDER_TYPES,
|
||||||
|
type Service,
|
||||||
|
type ServiceWorkOrderType,
|
||||||
|
} from "@/domain/services/types/service";
|
||||||
|
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
|
||||||
|
|
||||||
|
const ICONS = [
|
||||||
|
{ key: "wrench", label: "Wrench", Icon: Wrench },
|
||||||
|
{ key: "droplets", label: "Droplets", Icon: Droplets },
|
||||||
|
{ key: "zap", label: "Zap", Icon: Zap },
|
||||||
|
{ key: "hammer", label: "Hammer", Icon: Hammer },
|
||||||
|
{ key: "settings", label: "Settings", Icon: Settings2 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
|
||||||
|
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
|
||||||
|
const Icon = icon.Icon;
|
||||||
|
return <Icon size={size} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailsProps = {
|
||||||
|
service: Service | null;
|
||||||
|
open: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
canDeactivate: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: (service: Service) => void;
|
||||||
|
onDeactivate: (service: Service) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceDetailsDrawer({
|
||||||
|
service,
|
||||||
|
open,
|
||||||
|
canManage,
|
||||||
|
canDeactivate,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onDeactivate,
|
||||||
|
}: DetailsProps) {
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
|
{service !== null && (
|
||||||
|
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
|
||||||
|
<Box className="flex items-start justify-between p-5">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" className="font-display font-bold">
|
||||||
|
{service.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Service details
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box className="flex-1 overflow-y-auto p-5">
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<ServiceIcon iconKey={service.iconKey} size={24} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Trade
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" className="font-semibold">
|
||||||
|
{service.trade}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="mb-1 font-semibold">
|
||||||
|
Work Order Types
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||||
|
{service.supportedWorkOrderTypes.map((type) => (
|
||||||
|
<Chip key={type} size="small" label={type} variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="mb-1 font-semibold">
|
||||||
|
Completion Document
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{service.requiresCompletionDocument
|
||||||
|
? service.completionDocTemplate?.name || "No template linked yet"
|
||||||
|
: "Not required"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
|
||||||
|
{canManage && (
|
||||||
|
<Button variant="outlined" onClick={() => onEdit(service)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDeactivate && service.isActive && (
|
||||||
|
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorProps = {
|
||||||
|
mode: "add" | "edit" | null;
|
||||||
|
form: ServiceForm;
|
||||||
|
formError: string;
|
||||||
|
trades: DropdownOption[];
|
||||||
|
templates: TaskTemplate[];
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
|
||||||
|
onToggleType: (type: ServiceWorkOrderType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceEditorDrawer({
|
||||||
|
mode,
|
||||||
|
form,
|
||||||
|
formError,
|
||||||
|
trades,
|
||||||
|
templates,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onUpdate,
|
||||||
|
onToggleType,
|
||||||
|
}: EditorProps) {
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
|
||||||
|
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
|
||||||
|
<Box className="p-5">
|
||||||
|
<Typography variant="h6" className="font-display font-bold">
|
||||||
|
{mode === "add" ? "Add Service" : "Edit Service"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
|
Register a service and whether it needs a completion document.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box className="flex-1 overflow-y-auto p-5">
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{Boolean(formError) && <AlertMessage message={formError} />}
|
||||||
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
|
Service Information
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Service Name"
|
||||||
|
value={form.name}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
onChange={(event) => onUpdate("name", event.target.value)}
|
||||||
|
placeholder="e.g., HVAC Inspection"
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel id="service-trade-label">Trade</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="service-trade-label"
|
||||||
|
label="Trade"
|
||||||
|
value={form.trade}
|
||||||
|
onChange={(event) => onUpdate("trade", event.target.value)}
|
||||||
|
>
|
||||||
|
{trades.map((trade) => (
|
||||||
|
<MenuItem key={String(trade.id)} value={trade.value}>
|
||||||
|
{trade.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel id="service-icon-label">Icon</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="service-icon-label"
|
||||||
|
label="Icon"
|
||||||
|
value={form.iconKey}
|
||||||
|
onChange={(event) => onUpdate("iconKey", event.target.value)}
|
||||||
|
renderValue={(value) => {
|
||||||
|
const icon = ICONS.find((item) => item.key === value);
|
||||||
|
return icon ? (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<icon.Icon size={18} />
|
||||||
|
<span>{icon.label}</span>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
"Pick an icon"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ICONS.map(({ key, label, Icon }) => (
|
||||||
|
<MenuItem key={key} value={key}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Stack>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
|
Supported Work Order Types
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||||
|
{SERVICE_WORK_ORDER_TYPES.map((type) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={type}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={form.supportedWorkOrderTypes.includes(type)}
|
||||||
|
onChange={() => onToggleType(type)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={type}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
|
Completion Document
|
||||||
|
</Typography>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={form.requiresCompletionDocument}
|
||||||
|
onChange={(event) => {
|
||||||
|
onUpdate("requiresCompletionDocument", event.target.checked);
|
||||||
|
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Requires completion document"
|
||||||
|
/>
|
||||||
|
{form.requiresCompletionDocument && (
|
||||||
|
<Autocomplete
|
||||||
|
options={templates}
|
||||||
|
value={
|
||||||
|
templates.find((template) => template.id === form.completionDocTemplateId) ?? null
|
||||||
|
}
|
||||||
|
onChange={(_, template) =>
|
||||||
|
onUpdate("completionDocTemplateId", template?.id ?? null)
|
||||||
|
}
|
||||||
|
getOptionLabel={(template) => template.name}
|
||||||
|
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Linked Template" placeholder="Select a template" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Manage the document's own content on the Completion Document Templates page.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={onSave} disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertMessage({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||||
|
{message}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeactivateProps = {
|
||||||
|
service: Service | null;
|
||||||
|
open: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeactivateServiceDialog({
|
||||||
|
service,
|
||||||
|
open,
|
||||||
|
isPending,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: DeactivateProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose}>
|
||||||
|
<DialogTitle>Deactivate this service?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{service?.name} will no longer be selectable for new work orders. Existing work orders
|
||||||
|
will keep showing this service.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { SettingsNav } from "@/components/common/settings-nav";
|
||||||
|
import {
|
||||||
|
useServicesRegistryController,
|
||||||
|
type ServiceStatus,
|
||||||
|
} from "@/domain/services/use-cases/use-services-registry-controller";
|
||||||
|
import {
|
||||||
|
DeactivateServiceDialog,
|
||||||
|
ServiceDetailsDrawer,
|
||||||
|
ServiceEditorDrawer,
|
||||||
|
ServiceIcon,
|
||||||
|
} from "@/app/(protected)/settings/_components/services-registry-components";
|
||||||
|
|
||||||
|
export function ServicesRegistryView() {
|
||||||
|
const controller = useServicesRegistryController();
|
||||||
|
const activeCount = controller.services.filter((service) => service.isActive).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="p-4 md:p-8">
|
||||||
|
<SettingsNav />
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
className="mb-5"
|
||||||
|
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" className="font-display font-extrabold">
|
||||||
|
Services
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
|
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
|
||||||
|
{activeCount} active
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{controller.canManage && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={controller.openCreate}
|
||||||
|
startIcon={<Plus size={16} />}
|
||||||
|
>
|
||||||
|
Add Service
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={controller.search}
|
||||||
|
onChange={(event) => controller.setSearch(event.target.value)}
|
||||||
|
placeholder="Search services..."
|
||||||
|
className="flex-1"
|
||||||
|
aria-label="Search services"
|
||||||
|
/>
|
||||||
|
<FormControl size="small" className="min-w-[150px]">
|
||||||
|
<InputLabel id="service-status-label">Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="service-status-label"
|
||||||
|
label="Status"
|
||||||
|
value={controller.status}
|
||||||
|
onChange={(event) => controller.setStatus(event.target.value as ServiceStatus)}
|
||||||
|
>
|
||||||
|
<MenuItem value="active">Active</MenuItem>
|
||||||
|
<MenuItem value="inactive">Inactive</MenuItem>
|
||||||
|
<MenuItem value="all">All services</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{Boolean(controller.error) && (
|
||||||
|
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
|
||||||
|
)}
|
||||||
|
{controller.isLoading ? (
|
||||||
|
<Box className="flex justify-center py-16">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : controller.filteredServices.length === 0 ? (
|
||||||
|
<EmptyServices
|
||||||
|
search={Boolean(controller.search)}
|
||||||
|
onAdd={controller.canManage ? controller.openCreate : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
|
||||||
|
<Table aria-label="Services">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Service</TableCell>
|
||||||
|
<TableCell>Trade</TableCell>
|
||||||
|
<TableCell>Completion Document</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{controller.filteredServices.map((service) => (
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
key={String(service.id)}
|
||||||
|
onClick={() => controller.setSelected(service)}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
||||||
|
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<ServiceIcon iconKey={service.iconKey} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" className="font-semibold">
|
||||||
|
{service.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{service.supportedWorkOrderTypes.join(" · ")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{service.trade}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{service.requiresCompletionDocument
|
||||||
|
? service.completionDocTemplate?.name || "No template linked yet"
|
||||||
|
: "Not required"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
|
||||||
|
<TableCell align="right">›</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ServiceDetailsDrawer
|
||||||
|
service={controller.selected}
|
||||||
|
open={controller.selected !== null && controller.editorMode === null}
|
||||||
|
canManage={controller.canManage}
|
||||||
|
canDeactivate={controller.canDeactivate}
|
||||||
|
onClose={() => controller.setSelected(null)}
|
||||||
|
onEdit={controller.openEdit}
|
||||||
|
onDeactivate={controller.setDeactivateTarget}
|
||||||
|
/>
|
||||||
|
<ServiceEditorDrawer
|
||||||
|
mode={controller.editorMode}
|
||||||
|
form={controller.form}
|
||||||
|
formError={controller.formError}
|
||||||
|
trades={controller.trades}
|
||||||
|
templates={controller.templates}
|
||||||
|
isSaving={controller.isSaving}
|
||||||
|
onClose={controller.closeEditor}
|
||||||
|
onSave={controller.save}
|
||||||
|
onUpdate={controller.updateForm}
|
||||||
|
onToggleType={controller.toggleType}
|
||||||
|
/>
|
||||||
|
<DeactivateServiceDialog
|
||||||
|
service={controller.deactivateTarget}
|
||||||
|
open={controller.deactivateTarget !== null}
|
||||||
|
isPending={controller.isDeactivating}
|
||||||
|
onClose={() => controller.setDeactivateTarget(null)}
|
||||||
|
onConfirm={controller.confirmDeactivate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyServices({ search, onAdd }: { search: boolean; onAdd?: () => void }) {
|
||||||
|
return (
|
||||||
|
<Box className="rounded-lg border border-dashed border-border bg-background py-16 text-center">
|
||||||
|
<Typography variant="body1" className="font-semibold">
|
||||||
|
{search ? "No services found" : "No services configured yet"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
|
{search
|
||||||
|
? "Try a different search, or add a new service."
|
||||||
|
: "Add a service to make it available for work orders."}
|
||||||
|
</Typography>
|
||||||
|
{onAdd !== undefined && (
|
||||||
|
<Button variant="outlined" className="mt-4" onClick={onAdd}>
|
||||||
|
Add Service
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,611 +1,5 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
|
||||||
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() {
|
export default function ServicesPage() {
|
||||||
const { user } = useAuthContext();
|
return <ServicesRegistryView />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@ import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import { mapService, mapServices } from "@/domain/services/mappers/service-mapper";
|
import { mapService, mapServices } from "@/domain/services/mappers/service-mapper";
|
||||||
import type { Service, ServiceInput } from "@/domain/services/types/service";
|
import type { Service, ServiceInput } from "@/domain/services/types/service";
|
||||||
|
|
||||||
|
const SERVICE_WORK_ORDER_TYPE_API = {
|
||||||
|
PM: 2,
|
||||||
|
Emergency: 3,
|
||||||
|
Reactive: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toApiInput(input: ServiceInput) {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
|
||||||
|
(type) => SERVICE_WORK_ORDER_TYPE_API[type],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const servicesApi = {
|
export const servicesApi = {
|
||||||
getAll: async (isActive?: boolean): Promise<Service[]> => {
|
getAll: async (isActive?: boolean): Promise<Service[]> => {
|
||||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||||
|
|
@ -13,12 +28,12 @@ export const servicesApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (input: ServiceInput): Promise<Service> => {
|
create: async (input: ServiceInput): Promise<Service> => {
|
||||||
const data = await apiPost<unknown>(API_PATHS.services.list, input);
|
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
|
||||||
return mapService(handleApiResponse(data));
|
return mapService(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
|
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
|
||||||
const data = await apiPut<unknown>(API_PATHS.services.byId(id), input);
|
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
|
||||||
return mapService(handleApiResponse(data));
|
return mapService(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
import { getPrimaryUserRole, isAdminUser } from "@/lib/auth/user-utils";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
|
||||||
|
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
|
||||||
|
import {
|
||||||
|
useCreateService,
|
||||||
|
useDeactivateService,
|
||||||
|
useServices,
|
||||||
|
useUpdateService,
|
||||||
|
} from "@/domain/services/use-cases/use-services";
|
||||||
|
|
||||||
|
export type ServiceStatus = "all" | "active" | "inactive";
|
||||||
|
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
|
||||||
|
completionDocTemplateId: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_SERVICE_FORM: ServiceForm = {
|
||||||
|
name: "",
|
||||||
|
trade: "",
|
||||||
|
iconKey: "",
|
||||||
|
requiresCompletionDocument: false,
|
||||||
|
completionDocTemplateId: null,
|
||||||
|
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
|
||||||
|
HVAC: "droplets",
|
||||||
|
Plumbing: "droplets",
|
||||||
|
Electrical: "zap",
|
||||||
|
"General Building": "hammer",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildServiceInput(form: ServiceForm): {
|
||||||
|
input: ServiceInput | null;
|
||||||
|
error: string;
|
||||||
|
} {
|
||||||
|
const name = form.name.trim();
|
||||||
|
if (!name || !form.trade || !form.iconKey) {
|
||||||
|
return { input: null, error: "Service Name, Trade, and Icon are required." };
|
||||||
|
}
|
||||||
|
if (!form.supportedWorkOrderTypes.length) {
|
||||||
|
return { input: null, error: "Select at least one Work Order Type." };
|
||||||
|
}
|
||||||
|
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
|
||||||
|
return {
|
||||||
|
input: null,
|
||||||
|
error: "Select a completion document template when the document is required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const templateId = form.completionDocTemplateId;
|
||||||
|
return {
|
||||||
|
input: {
|
||||||
|
...form,
|
||||||
|
name,
|
||||||
|
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): ServiceForm {
|
||||||
|
return {
|
||||||
|
...EMPTY_SERVICE_FORM,
|
||||||
|
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromService(service: Service): ServiceForm {
|
||||||
|
return {
|
||||||
|
name: service.name,
|
||||||
|
trade: service.trade,
|
||||||
|
iconKey: service.iconKey,
|
||||||
|
requiresCompletionDocument: service.requiresCompletionDocument,
|
||||||
|
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
|
||||||
|
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServicesRegistryController() {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const role = getPrimaryUserRole(user?.userRoles).toLowerCase();
|
||||||
|
const canManage = isAdminUser(user?.userRoles) || role === "scheduler";
|
||||||
|
const canDeactivate = isAdminUser(user?.userRoles);
|
||||||
|
const [status, setStatus] = useState<ServiceStatus>("active");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selected, setSelected] = useState<Service | null>(null);
|
||||||
|
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
|
||||||
|
const [form, setForm] = useState<ServiceForm>(emptyForm);
|
||||||
|
const [iconTouched, setIconTouched] = useState(false);
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
|
||||||
|
|
||||||
|
const queryStatus = status === "active" ? true : status === "inactive" ? false : undefined;
|
||||||
|
const servicesQuery = useServices(queryStatus);
|
||||||
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
|
const { data: templates = [] } = useTaskTemplates();
|
||||||
|
const createService = useCreateService();
|
||||||
|
const updateService = useUpdateService();
|
||||||
|
const deactivateService = useDeactivateService();
|
||||||
|
|
||||||
|
const filteredServices = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
if (!needle) return servicesQuery.data ?? [];
|
||||||
|
return (servicesQuery.data ?? []).filter(
|
||||||
|
(service) =>
|
||||||
|
service.name.toLowerCase().includes(needle) || service.trade.toLowerCase().includes(needle),
|
||||||
|
);
|
||||||
|
}, [search, servicesQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!iconTouched && form.trade) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [form.trade, iconTouched]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setSelected(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setIconTouched(false);
|
||||||
|
setFormError("");
|
||||||
|
setEditorMode("add");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (service: Service) => {
|
||||||
|
setSelected(service);
|
||||||
|
setForm(formFromService(service));
|
||||||
|
setIconTouched(true);
|
||||||
|
setFormError("");
|
||||||
|
setEditorMode("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
|
||||||
|
if (key === "trade") setIconTouched(false);
|
||||||
|
setFormError("");
|
||||||
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleType = (type: ServiceWorkOrderType) => {
|
||||||
|
setForm((current) => {
|
||||||
|
const supported = current.supportedWorkOrderTypes.includes(type)
|
||||||
|
? current.supportedWorkOrderTypes.filter((item) => item !== type)
|
||||||
|
: [...current.supportedWorkOrderTypes, type];
|
||||||
|
return { ...current, supportedWorkOrderTypes: supported };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const validation = buildServiceInput(form);
|
||||||
|
if (!validation.input) {
|
||||||
|
setFormError(validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = validation.input;
|
||||||
|
if (editorMode === "add") {
|
||||||
|
createService.mutate(input, {
|
||||||
|
onSuccess: (created) => {
|
||||||
|
setEditorMode(null);
|
||||||
|
setSelected(created);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (editorMode === "edit" && selected) {
|
||||||
|
updateService.mutate(
|
||||||
|
{ id: selected.id, input },
|
||||||
|
{
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
setEditorMode(null);
|
||||||
|
setSelected(updated);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeactivate = () => {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
|
deactivateService.mutate(deactivateTarget.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeactivateTarget(null);
|
||||||
|
setSelected(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
canManage,
|
||||||
|
canDeactivate,
|
||||||
|
status,
|
||||||
|
setStatus,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
selected,
|
||||||
|
setSelected,
|
||||||
|
editorMode,
|
||||||
|
form,
|
||||||
|
formError,
|
||||||
|
trades,
|
||||||
|
templates,
|
||||||
|
filteredServices,
|
||||||
|
services: servicesQuery.data ?? [],
|
||||||
|
isLoading: servicesQuery.isLoading,
|
||||||
|
error: servicesQuery.error,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
closeEditor,
|
||||||
|
updateForm,
|
||||||
|
toggleType,
|
||||||
|
save,
|
||||||
|
deactivateTarget,
|
||||||
|
setDeactivateTarget,
|
||||||
|
confirmDeactivate,
|
||||||
|
isSaving: createService.isPending || updateService.isPending,
|
||||||
|
isDeactivating: deactivateService.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -67,8 +67,14 @@ describe("servicesApi", () => {
|
||||||
await servicesApi.update(8, input);
|
await servicesApi.update(8, input);
|
||||||
await servicesApi.deactivate(8);
|
await servicesApi.deactivate(8);
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, input);
|
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
|
||||||
|
...input,
|
||||||
|
supportedWorkOrderTypes: [2],
|
||||||
|
});
|
||||||
expect(apiPost).toHaveBeenNthCalledWith(2, API_PATHS.services.deactivate(8));
|
expect(apiPost).toHaveBeenNthCalledWith(2, API_PATHS.services.deactivate(8));
|
||||||
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), input);
|
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
|
||||||
|
...input,
|
||||||
|
supportedWorkOrderTypes: [2],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue