mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
|
import {
|
|
Accordion,
|
|
AccordionDetails,
|
|
AccordionSummary,
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
CircularProgress,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
FormControl,
|
|
FormControlLabel,
|
|
FormHelperText,
|
|
InputLabel,
|
|
MenuItem,
|
|
Select,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import { AREA_ZONES } from "@/data/areas";
|
|
import { useCreateTeamMember } from "@/domain/team-members/use-cases/use-create-team-member";
|
|
import type {
|
|
CreateTeamMemberInput,
|
|
TeamMemberRole,
|
|
} from "@/domain/team-members/types/team-member";
|
|
import {
|
|
defaultsForRole,
|
|
getFormErrors,
|
|
PERMISSION_GROUPS,
|
|
PERMISSION_LABELS,
|
|
permissionOverrides,
|
|
} from "./team-member-permissions";
|
|
import { TEAM_MEMBER_COLORS, TEAM_MEMBER_ROLES } from "./team-member-form-options";
|
|
|
|
const LEGACY_ROLE_OPTION = "__legacy-role__";
|
|
|
|
function emptyForm() {
|
|
return {
|
|
name: "",
|
|
role: "Dispatcher" as TeamMemberRole | "",
|
|
color: "",
|
|
colorNotice: "",
|
|
email: "",
|
|
phone: "",
|
|
active: true,
|
|
serviceAreas: [] as string[],
|
|
permissions: defaultsForRole("Dispatcher"),
|
|
};
|
|
}
|
|
|
|
export type TeamMemberForm = ReturnType<typeof emptyForm>;
|
|
type FormErrors = ReturnType<typeof getFormErrors>;
|
|
|
|
function PermissionsSection({
|
|
form,
|
|
togglePermission,
|
|
}: {
|
|
form: TeamMemberForm;
|
|
togglePermission: (key: string) => void;
|
|
}) {
|
|
return (
|
|
<Box>
|
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
|
Permissions
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
Permissions start with the selected role's defaults. Customize them before saving.
|
|
</Typography>
|
|
{PERMISSION_GROUPS.map((group) => (
|
|
<Accordion key={group.label} disableGutters>
|
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
<Typography>{group.label}</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Stack>
|
|
{group.keys.map((key) => (
|
|
<FormControlLabel
|
|
key={key}
|
|
control={
|
|
<Checkbox
|
|
checked={form.permissions.has(key)}
|
|
onChange={() => togglePermission(key)}
|
|
/>
|
|
}
|
|
label={PERMISSION_LABELS[key]}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function RoleSelectField({
|
|
role,
|
|
roleError,
|
|
updateRole,
|
|
disabled,
|
|
legacyRole,
|
|
}: {
|
|
role: TeamMemberRole | "";
|
|
roleError: string;
|
|
updateRole: (role: TeamMemberRole) => void;
|
|
disabled: boolean;
|
|
legacyRole: string;
|
|
}) {
|
|
const legacyRolePending = Boolean(legacyRole) && !role;
|
|
|
|
return (
|
|
<FormControl fullWidth required error={Boolean(roleError)}>
|
|
<InputLabel id="team-member-role-label">Role</InputLabel>
|
|
<Select
|
|
labelId="team-member-role-label"
|
|
label="Role"
|
|
value={legacyRolePending ? LEGACY_ROLE_OPTION : role}
|
|
disabled={disabled}
|
|
onChange={(event) => updateRole(event.target.value as TeamMemberRole)}
|
|
>
|
|
{legacyRolePending && (
|
|
<MenuItem value={LEGACY_ROLE_OPTION} disabled>
|
|
{legacyRole} (legacy)
|
|
</MenuItem>
|
|
)}
|
|
{TEAM_MEMBER_ROLES.map((option) => (
|
|
<MenuItem key={option} value={option}>
|
|
{option}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
{legacyRolePending && (
|
|
<FormHelperText error={Boolean(roleError)}>
|
|
{legacyRole} is a legacy role and can no longer be saved. Choose Dispatcher, Scheduler, or
|
|
Admin.
|
|
</FormHelperText>
|
|
)}
|
|
{!legacyRolePending && Boolean(roleError) && <FormHelperText>{roleError}</FormHelperText>}
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
export function TeamMemberFormFields({
|
|
form,
|
|
setForm,
|
|
errors,
|
|
updateRole,
|
|
toggleArea,
|
|
togglePermission,
|
|
roleDisabled = false,
|
|
legacyRole = "",
|
|
}: {
|
|
form: TeamMemberForm;
|
|
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
|
errors: FormErrors;
|
|
updateRole: (role: TeamMemberRole) => void;
|
|
toggleArea: (area: string) => void;
|
|
togglePermission: (key: string) => void;
|
|
roleDisabled?: boolean;
|
|
legacyRole?: string;
|
|
}) {
|
|
return (
|
|
<Stack spacing={2.5}>
|
|
<TextField
|
|
label="Name"
|
|
placeholder="Team member name"
|
|
value={form.name}
|
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
|
error={Boolean(errors.name)}
|
|
helperText={errors.name}
|
|
required
|
|
fullWidth
|
|
/>
|
|
<RoleSelectField
|
|
role={form.role}
|
|
roleError={errors.role}
|
|
updateRole={updateRole}
|
|
disabled={roleDisabled}
|
|
legacyRole={legacyRole}
|
|
/>
|
|
|
|
<Box>
|
|
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
|
Color <span aria-hidden="true">*</span>
|
|
</Typography>
|
|
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}>
|
|
{TEAM_MEMBER_COLORS.map((color) => (
|
|
<Button
|
|
key={color}
|
|
type="button"
|
|
aria-label={`Select color ${color}`}
|
|
aria-pressed={form.color === color}
|
|
onClick={() => setForm((current) => ({ ...current, color, colorNotice: "" }))}
|
|
sx={{
|
|
minWidth: 32,
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: "50%",
|
|
backgroundColor: color,
|
|
border: form.color === color ? "3px solid" : "2px solid transparent",
|
|
borderColor: form.color === color ? "text.primary" : "transparent",
|
|
boxShadow: form.color === color ? "0 0 0 2px white" : "none",
|
|
p: 0,
|
|
"&:hover": { backgroundColor: color, opacity: 0.85 },
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
{Boolean(form.colorNotice) && !errors.color && (
|
|
<FormHelperText>{form.colorNotice}</FormHelperText>
|
|
)}
|
|
{Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>}
|
|
</Box>
|
|
|
|
{form.role === "Dispatcher" && (
|
|
<Box>
|
|
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
|
Service Area <span aria-hidden="true">*</span>
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
{form.serviceAreas.length ? form.serviceAreas.join(", ") : "Select service areas…"}
|
|
</Typography>
|
|
<Stack direction="row" useFlexGap sx={{ flexWrap: "wrap", columnGap: 1 }}>
|
|
{AREA_ZONES.map((area) => (
|
|
<FormControlLabel
|
|
key={area}
|
|
control={
|
|
<Checkbox
|
|
checked={form.serviceAreas.includes(area)}
|
|
onChange={() => toggleArea(area)}
|
|
/>
|
|
}
|
|
label={area}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
{Boolean(errors.serviceAreas) && (
|
|
<FormHelperText error>{errors.serviceAreas}</FormHelperText>
|
|
)}
|
|
</Box>
|
|
)}
|
|
{form.role !== "Dispatcher" && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
Not applicable for this role
|
|
</Typography>
|
|
)}
|
|
|
|
<TextField
|
|
label="Phone"
|
|
placeholder="(555) 555-0100"
|
|
value={form.phone}
|
|
onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="Email"
|
|
placeholder="name@seahavenind.com"
|
|
value={form.email}
|
|
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
|
|
error={Boolean(errors.email)}
|
|
helperText={errors.email || "A valid email is required to send the invite"}
|
|
required
|
|
type="email"
|
|
fullWidth
|
|
/>
|
|
|
|
<PermissionsSection form={form} togglePermission={togglePermission} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const [form, setForm] = useState(emptyForm);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const submissionId = useRef(0);
|
|
const { error: mutationError, isPending, mutate, reset } = useCreateTeamMember();
|
|
|
|
useEffect(() => {
|
|
submissionId.current += 1;
|
|
reset();
|
|
if (open) {
|
|
setForm(emptyForm());
|
|
setSubmitted(false);
|
|
}
|
|
}, [open, reset]);
|
|
|
|
const handleClose = () => {
|
|
if (isPending) return;
|
|
|
|
submissionId.current += 1;
|
|
reset();
|
|
onClose();
|
|
};
|
|
|
|
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim());
|
|
const errors = useMemo(
|
|
() => getFormErrors(form, submitted, emailIsValid),
|
|
[emailIsValid, form, submitted],
|
|
);
|
|
const isValid =
|
|
Boolean(form.name.trim()) &&
|
|
Boolean(form.role) &&
|
|
Boolean(form.color) &&
|
|
emailIsValid &&
|
|
(form.role !== "Dispatcher" || form.serviceAreas.length > 0);
|
|
|
|
const updateRole = (role: TeamMemberRole) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
role,
|
|
serviceAreas: [],
|
|
permissions: defaultsForRole(role),
|
|
}));
|
|
};
|
|
|
|
const togglePermission = (key: string) => {
|
|
setForm((current) => {
|
|
const permissions = new Set(current.permissions);
|
|
if (permissions.has(key)) permissions.delete(key);
|
|
else permissions.add(key);
|
|
return { ...current, permissions };
|
|
});
|
|
};
|
|
|
|
const toggleArea = (area: string) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
serviceAreas: current.serviceAreas.includes(area)
|
|
? current.serviceAreas.filter((value) => value !== area)
|
|
: [...current.serviceAreas, area],
|
|
}));
|
|
};
|
|
|
|
const submit = () => {
|
|
setSubmitted(true);
|
|
if (!isValid || form.role === "") return;
|
|
|
|
const input: CreateTeamMemberInput = {
|
|
name: form.name.trim(),
|
|
role: form.role,
|
|
color: form.color,
|
|
email: form.email.trim(),
|
|
phone: form.phone.trim() || undefined,
|
|
serviceAreas: form.serviceAreas,
|
|
permissionOverrides: permissionOverrides(form.role, form.permissions),
|
|
};
|
|
const currentSubmissionId = submissionId.current;
|
|
mutate(input, {
|
|
onSuccess: () => {
|
|
if (submissionId.current !== currentSubmissionId) return;
|
|
reset();
|
|
onClose();
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md" scroll="paper">
|
|
<DialogTitle>Add Member</DialogTitle>
|
|
<DialogContent dividers>
|
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
|
Register a new team member.
|
|
</Typography>
|
|
{Boolean(mutationError) && (
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{mutationError?.message}
|
|
</Alert>
|
|
)}
|
|
<TeamMemberFormFields
|
|
form={form}
|
|
setForm={setForm}
|
|
errors={errors}
|
|
updateRole={updateRole}
|
|
toggleArea={toggleArea}
|
|
togglePermission={togglePermission}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={handleClose} disabled={isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="contained" onClick={submit} disabled={isPending}>
|
|
{isPending ? <CircularProgress size={20} color="inherit" /> : "Add Member"}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|