mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 15:33:12 +00:00
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:
parent
aa407bc012
commit
c0f7bb26eb
6 changed files with 295 additions and 39 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}.`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue