/ src / components / org-chart / org-chart-team-panel.tsx
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  }