org-chart-team-panel.tsx
1 'use client' 2 3 import { useEffect, useRef, useState } from 'react' 4 import { AgentAvatar } from '@/components/agents/agent-avatar' 5 import type { Agent } from '@/types' 6 7 const TEAM_COLORS = [ 8 '#6366F1', '#8B5CF6', '#EC4899', '#EF4444', 9 '#F59E0B', '#10B981', '#06B6D4', '#3B82F6', 10 ] 11 12 interface TeamInfo { 13 label: string 14 color: string | null 15 agentIds: string[] 16 } 17 18 interface Props { 19 teams: TeamInfo[] 20 agents: Record<string, Agent> 21 onBatchPatch: (patches: Array<{ id: string; patch: Partial<Agent> }>) => void 22 onClose: () => void 23 } 24 25 export function OrgChartTeamPanel({ teams, agents, onBatchPatch, onClose }: Props) { 26 const ref = useRef<HTMLDivElement>(null) 27 const [editingLabel, setEditingLabel] = useState<string | null>(null) 28 const [editValue, setEditValue] = useState('') 29 const [confirmDelete, setConfirmDelete] = useState<string | null>(null) 30 const [expandedTeam, setExpandedTeam] = useState<string | null>(null) 31 const [showAddAgent, setShowAddAgent] = useState<string | null>(null) 32 const [showNewTeam, setShowNewTeam] = useState(false) 33 const [newTeamName, setNewTeamName] = useState('') 34 const [newTeamConfirmed, setNewTeamConfirmed] = useState(false) 35 36 useEffect(() => { 37 const handler = (e: PointerEvent) => { 38 if (ref.current && !ref.current.contains(e.target as Node)) onClose() 39 } 40 document.addEventListener('pointerdown', handler) 41 return () => document.removeEventListener('pointerdown', handler) 42 }, [onClose]) 43 44 const renameTeam = (oldLabel: string, newLabel: string) => { 45 const trimmed = newLabel.trim() 46 if (!trimmed || trimmed === oldLabel) { setEditingLabel(null); return } 47 const team = teams.find((t) => t.label === oldLabel) 48 if (!team) return 49 const patches = team.agentIds.map((id) => ({ 50 id, 51 patch: { orgChart: { ...(agents[id]?.orgChart || {}), teamLabel: trimmed } } as Partial<Agent>, 52 })) 53 onBatchPatch(patches) 54 setEditingLabel(null) 55 if (expandedTeam === oldLabel) setExpandedTeam(trimmed) 56 } 57 58 const changeTeamColor = (label: string, color: string) => { 59 const team = teams.find((t) => t.label === label) 60 if (!team) return 61 const patches = team.agentIds.map((id) => ({ 62 id, 63 patch: { orgChart: { ...(agents[id]?.orgChart || {}), teamColor: color } } as Partial<Agent>, 64 })) 65 onBatchPatch(patches) 66 } 67 68 const deleteTeam = (label: string) => { 69 const team = teams.find((t) => t.label === label) 70 if (!team) return 71 const patches = team.agentIds.map((id) => ({ 72 id, 73 patch: { orgChart: { ...(agents[id]?.orgChart || {}), teamLabel: null, teamColor: null } } as Partial<Agent>, 74 })) 75 onBatchPatch(patches) 76 setConfirmDelete(null) 77 if (expandedTeam === label) setExpandedTeam(null) 78 } 79 80 const removeFromTeam = (agentId: string) => { 81 onBatchPatch([{ 82 id: agentId, 83 patch: { orgChart: { ...(agents[agentId]?.orgChart || {}), teamLabel: null, teamColor: null } } as Partial<Agent>, 84 }]) 85 } 86 87 const addToTeam = (agentId: string, teamLabel: string) => { 88 const team = teams.find((t) => t.label === teamLabel) 89 const teamColor = team?.color || null 90 91 // Find position near existing team members on the chart 92 let x: number | undefined 93 let y: number | undefined 94 const teamMembers = team?.agentIds || [] 95 const placedMembers = teamMembers 96 .map((id) => agents[id]) 97 .filter((a): a is Agent => !!a && a.orgChart?.x != null) 98 99 if (placedMembers.length > 0) { 100 // Place to the right of the rightmost team member 101 let maxX = -Infinity 102 let maxY = 0 103 for (const a of placedMembers) { 104 if ((a.orgChart?.x ?? 0) > maxX) { 105 maxX = a.orgChart?.x ?? 0 106 maxY = a.orgChart?.y ?? 0 107 } 108 } 109 x = maxX + 220 110 y = maxY 111 } 112 113 const orgChart = { 114 ...(agents[agentId]?.orgChart || {}), 115 teamLabel, 116 ...(teamColor ? { teamColor } : {}), 117 ...(x != null ? { x, y } : {}), 118 } 119 120 onBatchPatch([{ id: agentId, patch: { orgChart } as Partial<Agent> }]) 121 setShowAddAgent(null) 122 } 123 124 const placeTeamOnChart = (team: TeamInfo) => { 125 // Find the rightmost edge of existing chart positions to avoid overlap 126 let maxX = 0 127 for (const a of Object.values(agents)) { 128 if (a.orgChart?.x != null) { 129 const right = a.orgChart.x + 200 130 if (right > maxX) maxX = right 131 } 132 } 133 const startX = maxX + 80 // gap from existing nodes 134 const startY = 40 135 const colGap = 220 136 const rowGap = 130 137 const cols = Math.max(2, Math.ceil(Math.sqrt(team.agentIds.length))) 138 139 const patches = team.agentIds.map((id, i) => ({ 140 id, 141 patch: { 142 orgChart: { 143 ...(agents[id]?.orgChart || {}), 144 x: startX + (i % cols) * colGap, 145 y: startY + Math.floor(i / cols) * rowGap, 146 }, 147 } as Partial<Agent>, 148 })) 149 if (patches.length > 0) onBatchPatch(patches) 150 } 151 152 // Agents not assigned to any team 153 const teamAgentIds = new Set(teams.flatMap((t) => t.agentIds)) 154 const unassignedAgents = Object.values(agents).filter( 155 (a) => !a.trashedAt && !teamAgentIds.has(a.id), 156 ) 157 158 return ( 159 <div 160 ref={ref} 161 className="absolute top-14 right-4 z-40 w-[260px] bg-raised border border-white/[0.08] rounded-[12px] shadow-xl shadow-black/40" 162 onWheel={(e) => e.stopPropagation()} 163 onPointerDown={(e) => e.stopPropagation()} 164 > 165 <div className="px-3 py-2 border-b border-white/[0.06] flex items-center justify-between"> 166 <span className="text-[11px] font-700 uppercase tracking-wider text-text-3/60">Teams</span> 167 <button 168 onClick={onClose} 169 className="w-5 h-5 rounded-[4px] flex items-center justify-center text-text-3 hover:text-text hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none" 170 > 171 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 172 <path d="M18 6L6 18" /><path d="M6 6l12 12" /> 173 </svg> 174 </button> 175 </div> 176 177 <div className="max-h-[400px] overflow-y-auto p-2 flex flex-col gap-1"> 178 {teams.length === 0 && !showNewTeam && ( 179 <div className="text-[11px] text-text-3/40 text-center py-4">No teams yet. Create one below or assign a team via the detail panel.</div> 180 )} 181 {teams.map((team) => { 182 const isExpanded = expandedTeam === team.label 183 return ( 184 <div key={team.label} className="rounded-[8px] border border-transparent hover:border-white/[0.04]"> 185 {/* Team row */} 186 <div className="flex items-center gap-2 px-2 py-1.5 group"> 187 {/* Expand chevron */} 188 <button 189 onClick={() => setExpandedTeam(isExpanded ? null : team.label)} 190 className="w-3.5 h-3.5 flex items-center justify-center text-text-3/40 hover:text-text-2 bg-transparent border-none cursor-pointer transition-colors shrink-0" 191 > 192 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" 193 style={{ transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }} 194 > 195 <polyline points="9 18 15 12 9 6" /> 196 </svg> 197 </button> 198 199 {/* Color picker */} 200 <div className="relative"> 201 <button 202 className="w-4 h-4 rounded-full border border-white/[0.1] cursor-pointer hover:scale-110 transition-transform shrink-0" 203 style={{ background: team.color || '#6366F1' }} 204 title="Change color" 205 onClick={(e) => { 206 const picker = (e.currentTarget as HTMLElement).nextElementSibling as HTMLElement | null 207 if (picker) picker.classList.toggle('hidden') 208 }} 209 /> 210 <div className="hidden absolute top-6 left-0 z-50 bg-raised border border-white/[0.08] rounded-[8px] p-1.5 flex flex-wrap gap-1 shadow-lg w-[76px]"> 211 {TEAM_COLORS.map((c) => ( 212 <button 213 key={c} 214 className="w-4 h-4 rounded-full border border-white/[0.1] cursor-pointer hover:scale-110 transition-transform" 215 style={{ background: c }} 216 onClick={() => changeTeamColor(team.label, c)} 217 /> 218 ))} 219 </div> 220 </div> 221 222 {/* Label */} 223 {editingLabel === team.label ? ( 224 <input 225 autoFocus 226 value={editValue} 227 onChange={(e) => setEditValue(e.target.value)} 228 onBlur={() => renameTeam(team.label, editValue)} 229 onKeyDown={(e) => { if (e.key === 'Enter') renameTeam(team.label, editValue) }} 230 className="flex-1 px-1 py-0.5 text-[11px] bg-white/[0.04] border border-white/[0.08] rounded-[4px] text-text outline-none focus:border-accent-bright/30 min-w-0" 231 /> 232 ) : ( 233 <span 234 className="flex-1 text-[11px] font-500 text-text-2 truncate cursor-text" 235 onClick={() => { setEditingLabel(team.label); setEditValue(team.label) }} 236 > 237 {team.label} 238 </span> 239 )} 240 241 <span className="text-[10px] text-text-3/40 tabular-nums">{team.agentIds.length}</span> 242 243 {/* Place on chart */} 244 {(() => { 245 const unplaced = team.agentIds.filter((id) => { 246 const a = agents[id] 247 return a && a.orgChart?.x == null 248 }) 249 if (unplaced.length === 0) return null 250 return ( 251 <button 252 onClick={() => placeTeamOnChart(team)} 253 className="hidden group-hover:flex items-center justify-center w-4 h-4 rounded-[4px] text-text-3/40 hover:text-accent-bright bg-transparent border-none cursor-pointer transition-colors" 254 title="Place on chart" 255 > 256 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 257 <path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" /> 258 </svg> 259 </button> 260 ) 261 })()} 262 263 {/* Delete */} 264 {confirmDelete === team.label ? ( 265 <div className="flex gap-1"> 266 <button onClick={() => deleteTeam(team.label)} className="text-[9px] text-red-400 bg-transparent border-none cursor-pointer">Yes</button> 267 <button onClick={() => setConfirmDelete(null)} className="text-[9px] text-text-3 bg-transparent border-none cursor-pointer">No</button> 268 </div> 269 ) : ( 270 <button 271 onClick={() => setConfirmDelete(team.label)} 272 className="hidden group-hover:flex w-4 h-4 rounded-[4px] items-center justify-center text-text-3/40 hover:text-red-400 bg-transparent border-none cursor-pointer transition-colors" 273 > 274 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 275 <path d="M3 6h18" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /> 276 </svg> 277 </button> 278 )} 279 </div> 280 281 {/* Expanded member list */} 282 {isExpanded && ( 283 <div className="px-2 pb-2 flex flex-col gap-0.5"> 284 {team.agentIds.map((aid) => { 285 const a = agents[aid] 286 if (!a) return null 287 return ( 288 <div key={aid} className="flex items-center gap-2 pl-5 pr-1 py-1 rounded-[6px] hover:bg-white/[0.03] group/member"> 289 <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={18} /> 290 <span className="flex-1 text-[10px] text-text-2 truncate">{a.name}</span> 291 <span className="text-[9px] text-text-3/30 capitalize">{a.role || 'worker'}</span> 292 <button 293 onClick={() => removeFromTeam(aid)} 294 className="hidden group-hover/member:flex w-3.5 h-3.5 rounded-[3px] items-center justify-center text-text-3/30 hover:text-red-400 bg-transparent border-none cursor-pointer transition-colors" 295 title="Remove from team" 296 > 297 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 298 <path d="M18 6L6 18" /><path d="M6 6l12 12" /> 299 </svg> 300 </button> 301 </div> 302 ) 303 })} 304 305 {/* Add agent to team */} 306 {showAddAgent === team.label ? ( 307 <div className="pl-5 mt-1 flex flex-col gap-0.5 max-h-[120px] overflow-y-auto rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-1"> 308 {unassignedAgents.length === 0 ? ( 309 <div className="text-[10px] text-text-3/40 text-center py-2">All agents assigned</div> 310 ) : ( 311 unassignedAgents.map((a) => ( 312 <button 313 key={a.id} 314 onClick={() => addToTeam(a.id, team.label)} 315 className="flex items-center gap-2 px-1.5 py-1 rounded-[5px] hover:bg-white/[0.04] bg-transparent border-none cursor-pointer text-left w-full transition-colors" 316 > 317 <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={16} /> 318 <span className="text-[10px] text-text-3 truncate">{a.name}</span> 319 </button> 320 )) 321 )} 322 </div> 323 ) : ( 324 <button 325 onClick={() => setShowAddAgent(team.label)} 326 className="flex items-center gap-1 pl-5 py-1 text-[10px] text-text-3/40 hover:text-text-2 bg-transparent border-none cursor-pointer transition-colors" 327 > 328 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 329 <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> 330 </svg> 331 Add agent 332 </button> 333 )} 334 </div> 335 )} 336 </div> 337 ) 338 })} 339 340 {/* Create new team */} 341 {showNewTeam ? ( 342 <div className="px-2 py-1.5 flex flex-col gap-1.5 rounded-[8px] border border-white/[0.06] bg-white/[0.02]"> 343 {!newTeamConfirmed ? ( 344 /* Step 1: Name input */ 345 <input 346 autoFocus 347 value={newTeamName} 348 onChange={(e) => setNewTeamName(e.target.value)} 349 onKeyDown={(e) => { 350 if (e.key === 'Enter' && newTeamName.trim()) setNewTeamConfirmed(true) 351 if (e.key === 'Escape') { setShowNewTeam(false); setNewTeamName(''); setNewTeamConfirmed(false) } 352 }} 353 placeholder="Team name, then press Enter..." 354 className="w-full px-2 py-1.5 text-[11px] bg-white/[0.04] border border-white/[0.08] rounded-[6px] text-text outline-none focus:border-accent-bright/30 placeholder:text-text-3/40" 355 /> 356 ) : ( 357 /* Step 2: Pick agents */ 358 <> 359 <div className="flex items-center justify-between px-1"> 360 <span className="text-[10px] font-600 text-text-2 truncate">{newTeamName.trim()}</span> 361 <button 362 onClick={() => setNewTeamConfirmed(false)} 363 className="text-[9px] text-text-3 hover:text-text-2 bg-transparent border-none cursor-pointer" 364 > 365 rename 366 </button> 367 </div> 368 <div className="flex flex-col gap-0.5 max-h-[140px] overflow-y-auto"> 369 {unassignedAgents.length === 0 ? ( 370 <div className="text-[10px] text-text-3/40 text-center py-2">No unassigned agents</div> 371 ) : ( 372 unassignedAgents.map((a) => ( 373 <button 374 key={a.id} 375 onClick={() => { 376 const name = newTeamName.trim() 377 addToTeam(a.id, name) 378 setShowNewTeam(false) 379 setNewTeamName('') 380 setNewTeamConfirmed(false) 381 setExpandedTeam(name) 382 }} 383 className="flex items-center gap-2 px-1.5 py-1 rounded-[5px] hover:bg-white/[0.04] bg-transparent border-none cursor-pointer text-left w-full transition-colors" 384 > 385 <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={16} /> 386 <span className="text-[10px] text-text-3 truncate">{a.name}</span> 387 </button> 388 )) 389 )} 390 </div> 391 </> 392 )} 393 <button 394 onClick={() => { setShowNewTeam(false); setNewTeamName(''); setNewTeamConfirmed(false) }} 395 className="text-[9px] text-text-3/40 hover:text-text-2 bg-transparent border-none cursor-pointer self-center" 396 > 397 Cancel 398 </button> 399 </div> 400 ) : ( 401 <button 402 onClick={() => setShowNewTeam(true)} 403 className="flex items-center justify-center gap-1.5 w-full py-2 mt-1 rounded-[8px] border border-dashed border-white/[0.08] text-[10px] font-500 text-text-3 hover:text-text-2 hover:bg-white/[0.03] bg-transparent cursor-pointer transition-colors" 404 > 405 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 406 <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> 407 </svg> 408 New Team 409 </button> 410 )} 411 </div> 412 </div> 413 ) 414 }