Rebuild the org chart with a real tidy-tree layout and centered connectors

This commit is contained in:
ZacharyZhang-NY committed 2026-05-30 15:52:49 -04:00
1 parent 6f23ac2877
commit afd96fe42c
3 files changed
+209 -117

No files matched your search

@@ -1,7 +1,8 @@
import { useMemo, useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import { OrgChartTree } from "./team/OrgChartTree";
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
@@ -81,9 +82,9 @@ export function TeamPage({ snapshot, actions }: PageProps) {
{" "}{agents.length} {agents.length === 1 ? "agent" : "agents"}
</p>
<section className="card" style={{ padding: 32, marginBottom: 28, overflowX: "auto" }}>
<section className="card" style={{ padding: 32, marginBottom: 28 }}>
<div className="section-label">Org</div>
<OrgChart boardChair={employees[0] ?? null} agents={agents} />
<OrgChartTree boardChair={employees[0] ?? null} agents={agents} onSelectAgent={setEditAgentId} />
</section>
<div className="section-label" style={{ marginBottom: 10 }}>Roster</div>
@@ -164,80 +165,6 @@ export function TeamPage({ snapshot, actions }: PageProps) {
);
}
function OrgCard({ name, role, human = false }: { name: string; role: string; human?: boolean }) {
return (
<div
style={{
width: 220,
border: "1px solid var(--border)",
borderRadius: 12,
padding: "14px 14px 12px",
background: "var(--panel)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div className="row">
<Avatar name={name} bot={!human} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 540, letterSpacing: "-0.01em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div>
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{role}</div>
</div>
</div>
<div>
{human ? <Pill>Human</Pill> : <Pill tone="violet" dot>Agent</Pill>}
</div>
</div>
);
}
function OrgChart({ boardChair, agents }: { boardChair: EmployeeRecord | null; agents: AgentRecord[] }) {
const childrenByManager = useMemo(() => {
const known = new Set(agents.map((agent) => agent.id));
const map = new Map<string, AgentRecord[]>();
for (const agent of agents) {
// Agents whose manager is missing (or the top-level CEO) hang off the root.
const key = agent.managerAgentId && known.has(agent.managerAgentId) ? agent.managerAgentId : "__root__";
const list = map.get(key) ?? [];
list.push(agent);
map.set(key, list);
}
return map;
}, [agents]);
const roots = childrenByManager.get("__root__") ?? [];
if (!boardChair && roots.length === 0) return null;
return (
<div className="org-tree">
<ul>
<li>
{boardChair ? <OrgCard name={boardChair.displayName} role={boardChair.role} human /> : <OrgCard name="Org" role="Reports" />}
{roots.length > 0 && (
<ul>
{roots.map((agent) => <OrgNode key={agent.id} agent={agent} childrenByManager={childrenByManager} />)}
</ul>
)}
</li>
</ul>
</div>
);
}
function OrgNode({ agent, childrenByManager }: { agent: AgentRecord; childrenByManager: Map<string, AgentRecord[]> }) {
const reports = childrenByManager.get(agent.id) ?? [];
return (
<li>
<OrgCard name={agent.name} role={agent.title || agent.role} />
{reports.length > 0 && (
<ul>
{reports.map((report) => <OrgNode key={report.id} agent={report} childrenByManager={childrenByManager} />)}
</ul>
)}
</li>
);
}
interface RosterEntry {
id: string;
@@ -0,0 +1,173 @@
import { useEffect, useMemo, useRef } from "react";
import { Avatar } from "../../shared/ui";
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
const NODE_W = 208;
const NODE_H = 64;
const H_GAP = 24;
const V_GAP = 44;
const LEVEL_H = NODE_H + V_GAP;
interface LayoutNode {
id: string;
name: string;
role: string;
human: boolean;
agentId: string | null;
children: LayoutNode[];
x: number;
y: number;
width: number;
}
interface Edge {
px: number;
py: number;
cx: number;
cy: number;
}
/**
* Hierarchical reporting tree with a real layout pass: each subtree's width is
* measured bottom-up, children are laid out left-to-right, and every parent is
* centered over the span of its reports. Connectors are orthogonal elbows. The
* whole chart is sized to its content and scrolls horizontally when wide.
*/
export function OrgChartTree({
boardChair,
agents,
onSelectAgent,
}: {
boardChair: EmployeeRecord | null;
agents: AgentRecord[];
onSelectAgent: (agentId: string) => void;
}) {
const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
const scrollRef = useRef<HTMLDivElement>(null);
const rootX = layout?.root.x ?? 0;
// Open the chart looking at the top of the company, not a wall of whitespace.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollLeft = Math.max(0, rootX - el.clientWidth / 2);
}, [rootX]);
if (!layout) return null;
const { root, width, height } = layout;
const nodes = flatten(root);
const edges = collectEdges(root);
return (
<div ref={scrollRef} className="org-scroll">
<div className="org-canvas" style={{ position: "relative", width, height, margin: "20px auto 4px" }}>
<svg width={width} height={height} style={{ position: "absolute", inset: 0, pointerEvents: "none" }} aria-hidden="true">
{edges.map((edge, index) => (
<path key={index} d={elbow(edge)} stroke="var(--border-strong)" fill="none" strokeWidth={1} />
))}
</svg>
{nodes.map((node) => (
<button
key={node.id}
type="button"
className={`org-node-card ${node.human ? "human" : ""}`.trim()}
style={{ left: node.x - NODE_W / 2, top: node.y, width: NODE_W, height: NODE_H }}
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
disabled={!node.agentId}
>
<Avatar name={node.name} bot={!node.human} size="sm" />
<span className="org-node-text">
<span className="org-node-name">{node.name}</span>
<span className="org-node-role">{node.role}</span>
</span>
</button>
))}
</div>
</div>
);
}
function buildLayout(
boardChair: EmployeeRecord | null,
agents: AgentRecord[],
): { root: LayoutNode; width: number; height: number } | null {
const known = new Set(agents.map((agent) => agent.id));
const byManager = new Map<string, AgentRecord[]>();
for (const agent of agents) {
const key = agent.managerAgentId && known.has(agent.managerAgentId) ? agent.managerAgentId : "__root__";
const siblings = byManager.get(key) ?? [];
siblings.push(agent);
byManager.set(key, siblings);
}
const toNode = (agent: AgentRecord): LayoutNode => ({
id: agent.id,
name: agent.name,
role: agent.title || agent.role,
human: false,
agentId: agent.id,
children: (byManager.get(agent.id) ?? []).map(toNode),
x: 0,
y: 0,
width: 0,
});
const roots = (byManager.get("__root__") ?? []).map(toNode);
if (!boardChair && roots.length === 0) return null;
const root: LayoutNode = boardChair
? { id: boardChair.id, name: boardChair.displayName, role: boardChair.role, human: true, agentId: null, children: roots, x: 0, y: 0, width: 0 }
: roots.length === 1
? roots[0]
: { id: "__org__", name: "Organization", role: "Reporting lines", human: false, agentId: null, children: roots, x: 0, y: 0, width: 0 };
measure(root);
let maxDepth = 0;
const place = (node: LayoutNode, left: number, depth: number) => {
maxDepth = Math.max(maxDepth, depth);
node.y = depth * LEVEL_H;
if (node.children.length === 0) {
node.x = left + NODE_W / 2;
return;
}
const span = childrenSpan(node.children);
let cursor = left + (node.width - span) / 2;
for (const child of node.children) {
place(child, cursor, depth + 1);
cursor += child.width + H_GAP;
}
node.x = (node.children[0].x + node.children[node.children.length - 1].x) / 2;
};
place(root, 0, 0);
return { root, width: root.width, height: (maxDepth + 1) * LEVEL_H - V_GAP };
}
function measure(node: LayoutNode): number {
if (node.children.length === 0) {
node.width = NODE_W;
return node.width;
}
const span = node.children.reduce((total, child) => total + measure(child), 0) + (node.children.length - 1) * H_GAP;
node.width = Math.max(NODE_W, span);
return node.width;
}
function childrenSpan(children: LayoutNode[]): number {
return children.reduce((total, child) => total + child.width, 0) + (children.length - 1) * H_GAP;
}
function flatten(node: LayoutNode): LayoutNode[] {
return [node, ...node.children.flatMap(flatten)];
}
function collectEdges(node: LayoutNode): Edge[] {
return node.children.flatMap((child) => [
{ px: node.x, py: node.y + NODE_H, cx: child.x, cy: child.y },
...collectEdges(child),
]);
}
function elbow(edge: Edge): string {
const midY = edge.py + V_GAP / 2;
return `M ${edge.px} ${edge.py} L ${edge.px} ${midY} L ${edge.cx} ${midY} L ${edge.cx} ${edge.cy}`;
}
+32 -40
View File
@@ -386,47 +386,39 @@ a { color: inherit; }
min-height: 0;
}
/* Hierarchical org chart: a real reporting tree with connector lines. */
.org-tree { display: inline-block; min-width: 100%; text-align: center; margin-top: 18px; }
.org-tree ul {
position: relative;
/* Hierarchical org chart: nodes are absolutely positioned by a layout pass,
connectors are drawn in an SVG underlay (see OrgChartTree). */
.org-scroll { overflow-x: auto; overflow-y: hidden; }
.org-node-card {
position: absolute;
display: flex;
justify-content: center;
margin: 0;
padding: 22px 0 0 0;
list-style: none;
align-items: center;
gap: 10px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
box-shadow: var(--shadow-sm);
text-align: left;
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.org-tree li {
position: relative;
padding: 22px 8px 0 8px;
list-style: none;
.org-node-card:hover:not(:disabled) { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
.org-node-card:disabled { cursor: default; }
.org-node-card.human { border-color: var(--amber); background: var(--amber-soft); }
.org-node-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.org-node-name {
font-size: 12.5px;
font-weight: 560;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-tree li::before, .org-tree li::after {
content: "";
position: absolute;
top: 0;
right: 50%;
width: 50%;
height: 22px;
border-top: 1px solid var(--border-strong);
.org-node-role {
font-size: 11px;
color: var(--text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-tree li::after {
right: auto;
left: 50%;
border-left: 1px solid var(--border-strong);
}
.org-tree li:only-child::before, .org-tree li:only-child::after { display: none; }
.org-tree li:only-child { padding-top: 22px; }
.org-tree li:first-child::before, .org-tree li:last-child::after { border: 0 none; }
.org-tree li:last-child::before { border-right: 1px solid var(--border-strong); }
.org-tree ul ul::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0;
height: 22px;
border-left: 1px solid var(--border-strong);
}
.org-tree > ul { padding-top: 0; }
.org-tree > ul > li:only-child { padding-top: 0; }