fix(team-members): keep legacy roles and colors editable in the detail drawer

Members created before the SH-326 model can carry data the current
backend contract rejects (PUT team-members accepts only the
Dispatcher/Scheduler/Admin roles and the ten palette colors), which made
them impossible to edit.

- Legacy roles (e.g. Manager) now show as "<role> (legacy)" in the edit
  form with helper copy asking to choose Dispatcher, Scheduler, or Admin;
  save stays blocked until one is chosen and the legacy value is never
  sent. View mode marks the stored role as legacy too.
- Stored colors outside the current palette are canonicalized when they
  only differ by casing; otherwise the nearest palette color (RGB
  distance, first as fallback) is preselected with a notice naming the
  replaced value, so saving never fails on an untouched legacy color.
- Role/color options move to team-member-form-options.ts with unit tests
  for canonicalization, nearest-color matching, and replacement notices.
This commit is contained in:
Codex Review Integration 2026-09-17 01:06:03 -03:00
parent aa407bc012
commit c0f7bb26eb
6 changed files with 295 additions and 39 deletions

View file

@ -36,26 +36,16 @@ import {
PERMISSION_LABELS, PERMISSION_LABELS,
permissionOverrides, permissionOverrides,
} from "./team-member-permissions"; } from "./team-member-permissions";
import { TEAM_MEMBER_COLORS, TEAM_MEMBER_ROLES } from "./team-member-form-options";
const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"]; const LEGACY_ROLE_OPTION = "__legacy-role__";
const COLORS = [
"#F59E0B",
"#0D9488",
"#6366F1",
"#F43F5E",
"#7C3AED",
"#0EA5E9",
"#16A34A",
"#EA580C",
"#0891B2",
"#C026D3",
] as const;
function emptyForm() { function emptyForm() {
return { return {
name: "", name: "",
role: "Dispatcher" as TeamMemberRole, role: "Dispatcher" as TeamMemberRole | "",
color: "", color: "",
colorNotice: "",
email: "", email: "",
phone: "", phone: "",
active: true, active: true,
@ -109,6 +99,53 @@ function PermissionsSection({
); );
} }
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({ export function TeamMemberFormFields({
form, form,
setForm, setForm,
@ -117,6 +154,7 @@ export function TeamMemberFormFields({
toggleArea, toggleArea,
togglePermission, togglePermission,
roleDisabled = false, roleDisabled = false,
legacyRole = "",
}: { }: {
form: TeamMemberForm; form: TeamMemberForm;
setForm: Dispatch<SetStateAction<TeamMemberForm>>; setForm: Dispatch<SetStateAction<TeamMemberForm>>;
@ -125,6 +163,7 @@ export function TeamMemberFormFields({
toggleArea: (area: string) => void; toggleArea: (area: string) => void;
togglePermission: (key: string) => void; togglePermission: (key: string) => void;
roleDisabled?: boolean; roleDisabled?: boolean;
legacyRole?: string;
}) { }) {
return ( return (
<Stack spacing={2.5}> <Stack spacing={2.5}>
@ -138,36 +177,26 @@ export function TeamMemberFormFields({
required required
fullWidth fullWidth
/> />
<FormControl fullWidth required error={Boolean(errors.role)}> <RoleSelectField
<InputLabel id="team-member-role-label">Role</InputLabel> role={form.role}
<Select roleError={errors.role}
labelId="team-member-role-label" updateRole={updateRole}
label="Role" disabled={roleDisabled}
value={form.role} legacyRole={legacyRole}
disabled={roleDisabled} />
onChange={(event) => updateRole(event.target.value as TeamMemberRole)}
>
{ROLES.map((role) => (
<MenuItem key={role} value={role}>
{role}
</MenuItem>
))}
</Select>
{Boolean(errors.role) && <FormHelperText>{errors.role}</FormHelperText>}
</FormControl>
<Box> <Box>
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}> <Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
Color <span aria-hidden="true">*</span> Color <span aria-hidden="true">*</span>
</Typography> </Typography>
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}> <Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}>
{COLORS.map((color) => ( {TEAM_MEMBER_COLORS.map((color) => (
<Button <Button
key={color} key={color}
type="button" type="button"
aria-label={`Select color ${color}`} aria-label={`Select color ${color}`}
aria-pressed={form.color === color} aria-pressed={form.color === color}
onClick={() => setForm((current) => ({ ...current, color }))} onClick={() => setForm((current) => ({ ...current, color, colorNotice: "" }))}
sx={{ sx={{
minWidth: 32, minWidth: 32,
width: 32, width: 32,
@ -183,6 +212,9 @@ export function TeamMemberFormFields({
/> />
))} ))}
</Stack> </Stack>
{Boolean(form.colorNotice) && !errors.color && (
<FormHelperText>{form.colorNotice}</FormHelperText>
)}
{Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>} {Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>}
</Box> </Box>
@ -297,7 +329,7 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
const submit = () => { const submit = () => {
setSubmitted(true); setSubmitted(true);
if (!isValid) return; if (!isValid || form.role === "") return;
const input: CreateTeamMemberInput = { const input: CreateTeamMemberInput = {
name: form.name.trim(), name: form.name.trim(),

View file

@ -36,6 +36,7 @@ import {
} from "./team-member-permissions"; } from "./team-member-permissions";
import { getFormErrors } from "./team-member-permissions"; import { getFormErrors } from "./team-member-permissions";
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog"; import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
import { import {
useTeamMemberDetail, useTeamMemberDetail,
useUpdateTeamMember, useUpdateTeamMember,
@ -47,10 +48,12 @@ import type {
} from "@/domain/team-members/types/team-member"; } from "@/domain/team-members/types/team-member";
function formFromMember(member: TeamMemberDetail): TeamMemberForm { function formFromMember(member: TeamMemberDetail): TeamMemberForm {
const { color, notice: colorNotice } = resolveFormColor(member.color);
return { return {
name: member.name, name: member.name,
role: (member.role as TeamMemberRole) || "Dispatcher", role: canonicalRole(member.role) ?? "",
color: member.color, color,
colorNotice,
email: member.email, email: member.email,
phone: member.phone, phone: member.phone,
active: member.status === "Active", active: member.status === "Active",
@ -68,6 +71,7 @@ function emptyForm(): TeamMemberForm {
name: "", name: "",
role: "Dispatcher", role: "Dispatcher",
color: "", color: "",
colorNotice: "",
email: "", email: "",
phone: "", phone: "",
active: true, active: true,
@ -147,7 +151,10 @@ function MemberView({ member }: { member: TeamMemberDetail }) {
</Stack> </Stack>
<Stack spacing={1.5}> <Stack spacing={1.5}>
<Field label="Name" value={member.name} /> <Field label="Name" value={member.name} />
<Field label="Role" value={member.role} /> <Field
label="Role"
value={canonicalRole(member.role) ? member.role : `${member.role} (legacy)`}
/>
<Field label="Color" value={member.color} /> <Field label="Color" value={member.color} />
<Field <Field
label="Service Area" label="Service Area"
@ -173,6 +180,7 @@ function MemberEditForm({
toggleArea, toggleArea,
togglePermission, togglePermission,
roleDisabled, roleDisabled,
legacyRole,
}: { }: {
form: TeamMemberForm; form: TeamMemberForm;
setForm: Dispatch<SetStateAction<TeamMemberForm>>; setForm: Dispatch<SetStateAction<TeamMemberForm>>;
@ -181,6 +189,7 @@ function MemberEditForm({
toggleArea: (area: string) => void; toggleArea: (area: string) => void;
togglePermission: (key: string) => void; togglePermission: (key: string) => void;
roleDisabled: boolean; roleDisabled: boolean;
legacyRole: string;
}) { }) {
return ( return (
<TeamMemberFormFields <TeamMemberFormFields
@ -191,6 +200,7 @@ function MemberEditForm({
toggleArea={toggleArea} toggleArea={toggleArea}
togglePermission={togglePermission} togglePermission={togglePermission}
roleDisabled={roleDisabled} roleDisabled={roleDisabled}
legacyRole={legacyRole}
/> />
); );
} }
@ -297,6 +307,7 @@ function useTeamMemberEditor(
emailIsValid && emailIsValid &&
(form.role !== "Dispatcher" || form.serviceAreas.length > 0); (form.role !== "Dispatcher" || form.serviceAreas.length > 0);
const protectedOwner = currentMember?.isAccountOwner === true; const protectedOwner = currentMember?.isAccountOwner === true;
const legacyRole = currentMember && !canonicalRole(currentMember.role) ? currentMember.role : "";
const updateRole = (role: TeamMemberRole) => { const updateRole = (role: TeamMemberRole) => {
setForm((current) => ({ setForm((current) => ({
@ -327,7 +338,7 @@ function useTeamMemberEditor(
const save = () => { const save = () => {
setSubmitted(true); setSubmitted(true);
if (!isValid || !currentMember) return; if (!isValid || !currentMember || form.role === "") return;
updateTeamMember.mutate( updateTeamMember.mutate(
{ {
@ -353,6 +364,7 @@ function useTeamMemberEditor(
setForm, setForm,
errors, errors,
protectedOwner, protectedOwner,
legacyRole,
isPending: updateTeamMember.isPending, isPending: updateTeamMember.isPending,
updateRole, updateRole,
toggleArea, toggleArea,
@ -426,6 +438,7 @@ function DrawerContent({
toggleArea={editor.toggleArea} toggleArea={editor.toggleArea}
togglePermission={editor.togglePermission} togglePermission={editor.togglePermission}
roleDisabled={editor.protectedOwner} roleDisabled={editor.protectedOwner}
legacyRole={editor.legacyRole}
/> />
)} )}
</Box> </Box>

View file

@ -0,0 +1,78 @@
import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
export const TEAM_MEMBER_ROLES: readonly TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"];
export const TEAM_MEMBER_COLORS = [
"#F59E0B",
"#0D9488",
"#6366F1",
"#F43F5E",
"#7C3AED",
"#0EA5E9",
"#16A34A",
"#EA580C",
"#0891B2",
"#C026D3",
] as const;
/** Returns the supported role matching the stored value (case-insensitive), or null for legacy roles. */
export function canonicalRole(value: string): TeamMemberRole | null {
const trimmed = value.trim();
return TEAM_MEMBER_ROLES.find((role) => role.toLowerCase() === trimmed.toLowerCase()) ?? null;
}
function parseHexColor(value: string): [number, number, number] | null {
const match = /^#?([0-9a-f]{6})$/i.exec(value.trim());
if (!match) return null;
const numeric = Number.parseInt(match[1], 16);
return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255];
}
/** Returns the palette color closest to the stored color by RGB distance, or the first palette color when unparseable. */
export function nearestPaletteColor(value: string): string {
const target = parseHexColor(value);
if (!target) return TEAM_MEMBER_COLORS[0];
let nearest: string = TEAM_MEMBER_COLORS[0];
let nearestDistance = Number.POSITIVE_INFINITY;
for (const color of TEAM_MEMBER_COLORS) {
const [red, green, blue] = parseHexColor(color) ?? [0, 0, 0];
const distance = (red - target[0]) ** 2 + (green - target[1]) ** 2 + (blue - target[2]) ** 2;
if (distance < nearestDistance) {
nearest = color;
nearestDistance = distance;
}
}
return nearest;
}
export interface ResolvedFormColor {
color: string;
notice: string;
}
/**
* Maps a stored color onto the current palette. Palette colors (any casing) are
* canonicalized; legacy colors are replaced by the nearest palette color with a
* notice so the replacement is never silent.
*/
export function resolveFormColor(stored: string): ResolvedFormColor {
const trimmed = stored.trim();
if (!trimmed) {
return {
color: TEAM_MEMBER_COLORS[0],
notice: `No color was saved for this member; preselected ${TEAM_MEMBER_COLORS[0]} from the palette.`,
};
}
const canonical = TEAM_MEMBER_COLORS.find(
(color) => color.toLowerCase() === trimmed.toLowerCase(),
);
if (canonical) return { color: canonical, notice: "" };
const nearest = nearestPaletteColor(trimmed);
return {
color: nearest,
notice: `Saved color ${trimmed} is not in the current palette and was replaced with the nearest palette color ${nearest}.`,
};
}

View file

@ -88,7 +88,7 @@ export function permissionOverrides(role: TeamMemberRole, selected: Set<string>)
export interface TeamMemberFormValues { export interface TeamMemberFormValues {
name: string; name: string;
role: TeamMemberRole; role: TeamMemberRole | "";
color: string; color: string;
email: string; email: string;
phone: string; phone: string;

View file

@ -158,4 +158,83 @@ describe("TeamMemberDetailDrawer", () => {
expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0); expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0);
expect(screen.queryByDisplayValue("Discarded draft")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("Discarded draft")).not.toBeInTheDocument();
}); });
it("marks a legacy role in view mode", () => {
vi.mocked(useTeamMemberDetail).mockReturnValue({
data: { ...detail, role: "Manager" },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useTeamMemberDetail>);
renderWithProviders(<TeamMemberDetailDrawer member={member} onClose={vi.fn()} />, {
withAuth: false,
});
expect(screen.getByText("Manager (legacy)")).toBeInTheDocument();
});
it("shows a legacy role in the edit form and blocks save until a supported role is chosen", async () => {
vi.mocked(useTeamMemberDetail).mockReturnValue({
data: { ...detail, role: "Manager" },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useTeamMemberDetail>);
const user = userEvent.setup();
renderWithProviders(<TeamMemberDetailDrawer member={member} onClose={vi.fn()} />, {
withAuth: false,
});
await user.click(screen.getByRole("button", { name: "Edit" }));
const roleSelect = screen.getByRole("combobox", { name: "Role" });
expect(roleSelect).toHaveTextContent("Manager (legacy)");
expect(
screen.getByText(/Manager is a legacy role and can no longer be saved/),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(mutate).not.toHaveBeenCalled();
await user.click(roleSelect);
await user.click(screen.getByRole("option", { name: "Scheduler" }));
await user.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
id: "u-1",
input: expect.objectContaining({ role: "Scheduler" }),
}),
expect.any(Object),
);
});
it("preselects the nearest palette color for a legacy color and saves without a manual pick", async () => {
vi.mocked(useTeamMemberDetail).mockReturnValue({
data: { ...detail, color: "#FF0000" },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useTeamMemberDetail>);
const user = userEvent.setup();
renderWithProviders(<TeamMemberDetailDrawer member={member} onClose={vi.fn()} />, {
withAuth: false,
});
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(
screen.getByText(/Saved color #FF0000 is not in the current palette/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Select color #EA580C" })).toHaveAttribute(
"aria-pressed",
"true",
);
await user.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({ color: "#EA580C" }),
}),
expect.any(Object),
);
});
}); });

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
canonicalRole,
nearestPaletteColor,
resolveFormColor,
TEAM_MEMBER_COLORS,
} from "@/app/(protected)/team-members/_components/team-member-form-options";
describe("canonicalRole", () => {
it("returns the canonical role for supported values", () => {
expect(canonicalRole("Dispatcher")).toBe("Dispatcher");
expect(canonicalRole("scheduler")).toBe("Scheduler");
expect(canonicalRole(" Admin ")).toBe("Admin");
});
it("returns null for legacy and empty roles", () => {
expect(canonicalRole("Manager")).toBeNull();
expect(canonicalRole("Supervisor")).toBeNull();
expect(canonicalRole("")).toBeNull();
});
});
describe("nearestPaletteColor", () => {
it("returns the closest palette color by RGB distance", () => {
expect(nearestPaletteColor("#FF0000")).toBe("#EA580C");
expect(nearestPaletteColor("#00FF00")).toBe("#16A34A");
expect(nearestPaletteColor("#111111")).toBe("#16A34A");
});
it("falls back to the first palette color for unparseable values", () => {
expect(nearestPaletteColor("not-a-color")).toBe(TEAM_MEMBER_COLORS[0]);
expect(nearestPaletteColor("")).toBe(TEAM_MEMBER_COLORS[0]);
});
});
describe("resolveFormColor", () => {
it("keeps palette colors and canonicalizes their casing", () => {
expect(resolveFormColor("#F59E0B")).toEqual({ color: "#F59E0B", notice: "" });
expect(resolveFormColor("#f59e0b")).toEqual({ color: "#F59E0B", notice: "" });
});
it("replaces a legacy color with the nearest palette color and explains the replacement", () => {
const resolved = resolveFormColor("#FF0000");
expect(resolved.color).toBe("#EA580C");
expect(resolved.notice).toContain("#FF0000");
expect(resolved.notice).toContain("#EA580C");
});
it("preselects the first palette color when nothing was stored", () => {
const resolved = resolveFormColor(" ");
expect(resolved.color).toBe(TEAM_MEMBER_COLORS[0]);
expect(resolved.notice).not.toBe("");
});
});