diff --git a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx
index 21aea977..170f3ccf 100644
--- a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx
+++ b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx
@@ -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 (
+
+ Role
+
+ {legacyRolePending && (
+
+ {legacyRole} is a legacy role and can no longer be saved. Choose Dispatcher, Scheduler, or
+ Admin.
+
+ )}
+ {!legacyRolePending && Boolean(roleError) && {roleError}}
+
+ );
+}
+
export function TeamMemberFormFields({
form,
setForm,
@@ -117,6 +154,7 @@ export function TeamMemberFormFields({
toggleArea,
togglePermission,
roleDisabled = false,
+ legacyRole = "",
}: {
form: TeamMemberForm;
setForm: Dispatch>;
@@ -125,6 +163,7 @@ export function TeamMemberFormFields({
toggleArea: (area: string) => void;
togglePermission: (key: string) => void;
roleDisabled?: boolean;
+ legacyRole?: string;
}) {
return (
@@ -138,36 +177,26 @@ export function TeamMemberFormFields({
required
fullWidth
/>
-
- Role
-
- {Boolean(errors.role) && {errors.role}}
-
+
Color *
- {COLORS.map((color) => (
+ {TEAM_MEMBER_COLORS.map((color) => (
+ {Boolean(form.colorNotice) && !errors.color && (
+ {form.colorNotice}
+ )}
{Boolean(errors.color) && {errors.color}}
@@ -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(),
diff --git a/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx b/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx
index c5014fcb..7c00b463 100644
--- a/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx
+++ b/src/app/(protected)/team-members/_components/team-member-detail-drawer.tsx
@@ -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 }) {
-
+
>;
@@ -181,6 +189,7 @@ function MemberEditForm({
toggleArea: (area: string) => void;
togglePermission: (key: string) => void;
roleDisabled: boolean;
+ legacyRole: string;
}) {
return (
);
}
@@ -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}
/>
)}
diff --git a/src/app/(protected)/team-members/_components/team-member-form-options.ts b/src/app/(protected)/team-members/_components/team-member-form-options.ts
new file mode 100644
index 00000000..3f220a37
--- /dev/null
+++ b/src/app/(protected)/team-members/_components/team-member-form-options.ts
@@ -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}.`,
+ };
+}
diff --git a/src/app/(protected)/team-members/_components/team-member-permissions.ts b/src/app/(protected)/team-members/_components/team-member-permissions.ts
index 7ec90845..bd1097f5 100644
--- a/src/app/(protected)/team-members/_components/team-member-permissions.ts
+++ b/src/app/(protected)/team-members/_components/team-member-permissions.ts
@@ -88,7 +88,7 @@ export function permissionOverrides(role: TeamMemberRole, selected: Set)
export interface TeamMemberFormValues {
name: string;
- role: TeamMemberRole;
+ role: TeamMemberRole | "";
color: string;
email: string;
phone: string;
diff --git a/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx b/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx
index cc8cc7a7..e6dc25b1 100644
--- a/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx
+++ b/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx
@@ -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);
+ renderWithProviders(, {
+ 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);
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ 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);
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ 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),
+ );
+ });
});
diff --git a/src/test/app/(protected)/team-members/team-member-form-options.test.ts b/src/test/app/(protected)/team-members/team-member-form-options.test.ts
new file mode 100644
index 00000000..8d9dac7f
--- /dev/null
+++ b/src/test/app/(protected)/team-members/team-member-form-options.test.ts
@@ -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("");
+ });
+});