/** * RoleTable — Tanstack-table bulk editor for roles. * * Columns: * - checkbox (multi-select) * - icon (click to open IconPicker — deferred; currently read-only) * - name (inline editable via double-click) * - role_id (monospace, immutable) * - reports_to (dropdown) * - tools (count, clickable to open popover) * - agent (select) * - employees (count) * - actions (⋯ menu: Delete) * * Row click → selects the row in StructureEditor (opens Inspector). * Multi-select → bulk-edit bar at top ("Change agent for N roles", etc.) */ import { useMemo, useState, type ChangeEvent } from 'react' import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender, type ColumnDef, type SortingState, } from '@tanstack/react-table' import type { OrgRole, OrgEmployee } from '../types/visual' import { resolveRoleIcon } from './roleIcons' const EXTERNAL_AGENTS = ['codex', 'cursor', 'claude_code', 'opencode'] as const interface RoleTableProps { roles: OrgRole[] employees: OrgEmployee[] selectedIds: string[] onSelectRow: (id: string) => void onUpdateRole: (roleId: string, updates: { name?: string reports_to?: string preferred_external_agent?: string | null }) => void onDeleteRole: (roleId: string) => void readOnly?: boolean } interface TableRow { role_id: string name: string icon: string | null reports_to: string toolCount: number agent: string | null employeeCount: number } /* ── RoleTable ───────────────────────────────────────────────── */ export function RoleTable({ roles, employees, selectedIds, onSelectRow, onUpdateRole, onDeleteRole, readOnly, }: RoleTableProps) { const [sorting, setSorting] = useState([]) const [rowSelection, setRowSelection] = useState>({}) const [editingCell, setEditingCell] = useState<{ rowId: string; col: 'name' } | null>(null) const [nameBuffer, setNameBuffer] = useState('') const data: TableRow[] = useMemo(() => { const countByRole = new Map() for (const e of employees) { const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id] for (const roleId of roleIds) { if (roleId) countByRole.set(roleId, (countByRole.get(roleId) ?? 0) + 1) } } return roles.map(r => ({ role_id: r.role_id, name: r.name, icon: r.icon ?? null, reports_to: r.reports_to, toolCount: r.tools?.length ?? 0, agent: r.preferred_external_agent ?? null, employeeCount: countByRole.get(r.role_id) ?? 0, })) }, [roles, employees]) const reportsToOptions = useMemo( () => [{ id: 'owner', name: 'Owner' }, ...roles.map(r => ({ id: r.role_id, name: r.name }))], [roles], ) const commitName = (rowId: string) => { const trimmed = nameBuffer.trim() if (trimmed && trimmed !== roles.find(r => r.role_id === rowId)?.name) { onUpdateRole(rowId, { name: trimmed }) } setEditingCell(null) setNameBuffer('') } const columns: ColumnDef[] = useMemo(() => [ { id: 'select', header: ({ table }) => ( { if (el) el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() }} onChange={table.getToggleAllRowsSelectedHandler()} disabled={readOnly} aria-label="Select all" /> ), cell: ({ row }) => ( e.stopPropagation()} /> ), size: 32, enableSorting: false, }, { id: 'icon', header: '', cell: ({ row }) => ( ), size: 32, enableSorting: false, }, { id: 'name', header: 'Name', accessorKey: 'name', cell: ({ row }) => { const r = row.original const isEditing = editingCell?.rowId === r.role_id && editingCell.col === 'name' if (isEditing) { return ( setNameBuffer(e.target.value)} onBlur={() => commitName(r.role_id)} onKeyDown={e => { if (e.key === 'Enter') commitName(r.role_id) else if (e.key === 'Escape') { setEditingCell(null); setNameBuffer('') } }} onClick={e => e.stopPropagation()} /> ) } return ( { if (readOnly) return e.stopPropagation() setNameBuffer(r.name) setEditingCell({ rowId: r.role_id, col: 'name' }) }} title="Double-click to edit" > {r.name} ) }, }, { id: 'role_id', header: 'ID', accessorKey: 'role_id', cell: ({ row }) => {row.original.role_id}, }, { id: 'reports_to', header: 'Reports to', accessorKey: 'reports_to', cell: ({ row }) => { const r = row.original return ( ) }, }, { id: 'toolCount', header: 'Tools', accessorKey: 'toolCount', cell: ({ row }) => ( {row.original.toolCount} ), size: 64, }, { id: 'agent', header: 'Agent', accessorKey: 'agent', cell: ({ row }) => { const r = row.original return ( ) }, }, { id: 'employeeCount', header: 'People', accessorKey: 'employeeCount', cell: ({ row }) => {row.original.employeeCount}, size: 64, }, { id: 'actions', header: '', cell: ({ row }) => ( ), size: 40, enableSorting: false, }, ], [editingCell, nameBuffer, readOnly, reportsToOptions, onUpdateRole, onDeleteRole]) const table = useReactTable({ data, columns, state: { sorting, rowSelection }, onSortingChange: setSorting, onRowSelectionChange: setRowSelection, enableRowSelection: true, getRowId: (row) => row.role_id, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), }) const selectedCount = Object.values(rowSelection).filter(Boolean).length const selectedRoleIds = Object.keys(rowSelection).filter(id => rowSelection[id]) /* ── Bulk actions ────────────────────────────────────────── */ const bulkSetAgent = (agent: string | null) => { if (readOnly) return selectedRoleIds.forEach(id => onUpdateRole(id, { preferred_external_agent: agent })) setRowSelection({}) } const bulkDelete = () => { if (readOnly) return if (!confirm(`Delete ${selectedCount} roles? This cannot be undone.`)) return selectedRoleIds.forEach(id => onDeleteRole(id)) setRowSelection({}) } return (
{selectedCount > 0 && !readOnly && (
{selectedCount} selected
)}
{table.getHeaderGroups().map(hg => ( {hg.headers.map(h => ( ))} ))} {table.getRowModel().rows.length === 0 && ( )} {table.getRowModel().rows.map(row => { const isSelected = selectedIds.includes(row.original.role_id) return ( onSelectRow(row.original.role_id)} > {row.getVisibleCells().map(cell => ( ))} ) })}
{flexRender(h.column.columnDef.header, h.getContext())} {h.column.getIsSorted() === 'asc' && } {h.column.getIsSorted() === 'desc' && }
No roles. Add one via the "+ Add role" button.
{flexRender(cell.column.columnDef.cell, cell.getContext())}
) }