2026-09-16 19:05:02 -03:00
|
|
|
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 {
|
|
|
|
|
SERVICE_WORK_ORDER_TYPES,
|
2026-09-16 19:13:00 -03:00
|
|
|
type CompletionDocTemplateOption,
|
2026-09-16 19:05:02 -03:00
|
|
|
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[];
|
2026-09-16 19:13:00 -03:00
|
|
|
templates: CompletionDocTemplateOption[];
|
2026-09-16 19:05:02 -03:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|