shoc-frontend-new/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx

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>
);
}