Rebuild the org chart with a real tidy-tree layout and centered connectors
This commit is contained in:
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}`;
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user