Skip to content

TeamAssignmentEditor

Team assignment editor for configuring scheduling strategies and member weights. Displays team members with their role, priority, weight, booking count, and provides a visual distribution preview for round-robin strategy.

Terminal window
npx thebookingkit add team-assignment-editor
import { useState } from "react";
import { TeamAssignmentEditor } from "@/components/team-assignment-editor";
export function TeamSettings() {
const [members, setMembers] = useState([
{
userId: "alice",
displayName: "Alice Chen",
role: "admin",
priority: 5,
weight: 100,
recentBookingCount: 25,
},
{
userId: "bob",
displayName: "Bob Smith",
role: "member",
priority: 3,
weight: 80,
recentBookingCount: 20,
},
]);
const [strategy, setStrategy] = useState("round_robin");
const handleMemberUpdate = (userId, changes) => {
setMembers(
members.map((m) => (m.userId === userId ? { ...m, ...changes } : m))
);
};
const handleSave = async () => {
await fetch("/api/team/config", {
method: "PUT",
body: JSON.stringify({ strategy, members }),
});
};
return (
<TeamAssignmentEditor
members={members}
strategy={strategy}
onStrategyChange={setStrategy}
onMemberUpdate={handleMemberUpdate}
onSave={handleSave}
/>
);
}
export type AssignmentStrategy =
| "round_robin"
| "collective"
| "managed"
| "fixed";
export interface TeamMemberDisplay {
userId: string;
displayName: string;
role: "admin" | "member";
priority: number;
weight: number;
recentBookingCount: number;
isFixed?: boolean;
}
export interface TeamAssignmentEditorProps {
/** Current team members */
members: TeamMemberDisplay[];
/** Current assignment strategy */
strategy: AssignmentStrategy;
/** Called when strategy changes */
onStrategyChange: (strategy: AssignmentStrategy) => void;
/** Called when a member's config is updated */
onMemberUpdate: (
userId: string,
changes: Partial<Pick<TeamMemberDisplay, "weight" | "priority" | "isFixed">>,
) => void;
/** Called when settings are saved */
onSave?: () => Promise<void>;
/** Additional CSS class name */
className?: string;
/** Inline styles */
style?: React.CSSProperties;
}
import React, { useState, useCallback } from "react";
import { cn } from "../utils/cn.js";
/** Assignment strategy options */
export type AssignmentStrategy =
| "round_robin"
| "collective"
| "managed"
| "fixed";
/** A team member displayed in the editor */
export interface TeamMemberDisplay {
userId: string;
displayName: string;
role: "admin" | "member";
priority: number;
weight: number;
recentBookingCount: number;
isFixed?: boolean;
}
/** Props for the TeamAssignmentEditor component */
export interface TeamAssignmentEditorProps {
/** Current team members */
members: TeamMemberDisplay[];
/** Current assignment strategy */
strategy: AssignmentStrategy;
/** Called when strategy changes */
onStrategyChange: (strategy: AssignmentStrategy) => void;
/** Called when a member's config is updated */
onMemberUpdate: (
userId: string,
changes: Partial<Pick<TeamMemberDisplay, "weight" | "priority" | "isFixed">>,
) => void;
/** Called when settings are saved */
onSave?: () => Promise<void>;
/** Additional CSS class name */
className?: string;
/** Inline styles */
style?: React.CSSProperties;
}
const STRATEGY_LABELS: Record<AssignmentStrategy, string> = {
round_robin: "Round Robin",
collective: "Collective",
managed: "Managed",
fixed: "Fixed",
};
const STRATEGY_DESCRIPTIONS: Record<AssignmentStrategy, string> = {
round_robin:
"Bookings are distributed among members based on weight and priority.",
collective:
"Customers can only book when all team members are available simultaneously.",
managed:
"Admin creates a template event type; members inherit it with optional customization.",
fixed: "Bookings are always assigned to the designated fixed host.",
};
/**
* Team assignment editor for configuring scheduling strategy and member weights.
*
* Shows all team members with their role, priority, weight, booking count,
* and a distribution preview based on current weights.
*
* @example
* ```tsx
* <TeamAssignmentEditor
* members={teamMembers}
* strategy="round_robin"
* onStrategyChange={setStrategy}
* onMemberUpdate={handleMemberUpdate}
* />
* ```
*/
export function TeamAssignmentEditor({
members,
strategy,
onStrategyChange,
onMemberUpdate,
onSave,
className,
style,
}: TeamAssignmentEditorProps) {
const [saving, setSaving] = useState(false);
const totalWeight = members.reduce((sum, m) => sum + m.weight, 0);
const totalBookings = members.reduce(
(sum, m) => sum + m.recentBookingCount,
0,
);
const handleSave = useCallback(async () => {
if (!onSave) return;
setSaving(true);
try {
await onSave();
} finally {
setSaving(false);
}
}, [onSave]);
return (
<div
className={cn("tbk-team-assignment-editor", className)}
style={style}
>
<h2>Team Scheduling Configuration</h2>
{/* Strategy selector */}
<div className="tbk-strategy-selector">
<label className="tbk-label">Assignment Strategy</label>
<div className="tbk-strategy-options">
{(Object.keys(STRATEGY_LABELS) as AssignmentStrategy[]).map((s) => (
<button
key={s}
type="button"
className={cn(
"tbk-strategy-option",
strategy === s && "tbk-strategy-active",
)}
onClick={() => onStrategyChange(s)}
>
<strong>{STRATEGY_LABELS[s]}</strong>
<span>{STRATEGY_DESCRIPTIONS[s]}</span>
</button>
))}
</div>
</div>
{/* Members table */}
<div className="tbk-members-table">
<h3>Team Members</h3>
<table>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Priority</th>
{strategy === "round_robin" && <th>Weight</th>}
{strategy === "round_robin" && <th>Target %</th>}
<th>Recent Bookings</th>
{strategy === "round_robin" && <th>Fixed Host</th>}
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.userId}>
<td className="tbk-member-name">{member.displayName}</td>
<td>
<span className="tbk-role-badge">{member.role}</span>
</td>
<td>
<input
type="number"
className="tbk-input tbk-input-sm"
value={member.priority}
min={0}
max={10}
onChange={(e) =>
onMemberUpdate(member.userId, {
priority: parseInt(e.target.value, 10) || 0,
})
}
/>
</td>
{strategy === "round_robin" && (
<td>
<input
type="range"
className="tbk-slider"
value={member.weight}
min={1}
max={500}
onChange={(e) =>
onMemberUpdate(member.userId, {
weight: parseInt(e.target.value, 10),
})
}
/>
<span className="tbk-weight-value">
{member.weight}
</span>
</td>
)}
{strategy === "round_robin" && (
<td className="tbk-target-pct">
{totalWeight > 0
? `${((member.weight / totalWeight) * 100).toFixed(1)}%`
: ""}
</td>
)}
<td className="tbk-booking-count">
{member.recentBookingCount}
{totalBookings > 0 && (
<span className="tbk-actual-pct">
{" "}
(
{(
(member.recentBookingCount / totalBookings) *
100
).toFixed(1)}
%)
</span>
)}
</td>
{strategy === "round_robin" && (
<td>
<input
type="checkbox"
checked={member.isFixed ?? false}
onChange={(e) =>
onMemberUpdate(member.userId, {
isFixed: e.target.checked,
})
}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{/* Distribution preview for round-robin */}
{strategy === "round_robin" && members.length > 0 && (
<div className="tbk-distribution-preview">
<h3>Expected Distribution</h3>
<div className="tbk-distribution-bar">
{members.map((member) => {
const pct =
totalWeight > 0 ? (member.weight / totalWeight) * 100 : 0;
return (
<div
key={member.userId}
className="tbk-distribution-segment"
style={{ width: `${pct}%` }}
title={`${member.displayName}: ${pct.toFixed(1)}%`}
>
{pct >= 10 && (
<span>
{member.displayName.split(" ")[0]} ({pct.toFixed(0)}%)
</span>
)}
</div>
);
})}
</div>
</div>
)}
{onSave && (
<div className="tbk-form-actions">
<button
type="button"
className="tbk-button-primary"
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : "Save Configuration"}
</button>
</div>
)}
</div>
);
}