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,
permissionOverrides,
} from "./team-member-permissions";
import { TEAM_MEMBER_COLORS, TEAM_MEMBER_ROLES } from "./team-member-form-options";
const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"];
const COLORS = [
"#F59E0B",
"#0D9488",
"#6366F1",
"#F43F5E",
"#7C3AED",
"#0EA5E9",
"#16A34A",
"#EA580C",
"#0891B2",
"#C026D3",
] as const;
const LEGACY_ROLE_OPTION = "__legacy-role__";
function emptyForm() {
return {
name: "",
role: "Dispatcher" as TeamMemberRole,
role: "Dispatcher" as TeamMemberRole | "",
color: "",
colorNotice: "",
email: "",
phone: "",
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({
form,
setForm,
@ -117,6 +154,7 @@ export function TeamMemberFormFields({
toggleArea,
togglePermission,
roleDisabled = false,
legacyRole = "",
}: {
form: TeamMemberForm;
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
@ -125,6 +163,7 @@ export function TeamMemberFormFields({
toggleArea: (area: string) => void;
togglePermission: (key: string) => void;
roleDisabled?: boolean;
legacyRole?: string;
}) {
return (
<Stack spacing={2.5}>
@ -138,36 +177,26 @@ export function TeamMemberFormFields({
required
fullWidth
/>
<FormControl fullWidth required error={Boolean(errors.role)}>
<InputLabel id="team-member-role-label">Role</InputLabel>
<Select
labelId="team-member-role-label"
label="Role"
value={form.role}
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>
<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" }}>
{COLORS.map((color) => (
{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 }))}
onClick={() => setForm((current) => ({ ...current, color, colorNotice: "" }))}
sx={{
minWidth: 32,
width: 32,
@ -183,6 +212,9 @@ export function TeamMemberFormFields({
/>
))}
</Stack>
{Boolean(form.colorNotice) && !errors.color && (
<FormHelperText>{form.colorNotice}</FormHelperText>
)}
{Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>}
</Box>
@ -297,7 +329,7 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
const submit = () => {
setSubmitted(true);
if (!isValid) return;
if (!isValid || form.role === "") return;
const input: CreateTeamMemberInput = {
name: form.name.trim(),

View file

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

View file

@ -158,4 +158,83 @@ describe("TeamMemberDetailDrawer", () => {
expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0);
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("");
});
});