Skip to content
129 linesCodeBlameRaw
1import { Info } from "lucide-react";
2import { Link, data } from "react-router";
3
4import type { Route } from "./+types/org-chart";
5import { AgentPersonFace, PersonFace, TeamKindMark } from "../../components/people";
6import { Hint } from "../../components/ui/hint";
7import { page } from "../../lib/meta";
8import { type OrgNode, agentPath, orgChart, peopleAgent, personName, personPath } from "../../lib/people";
9import { teamPath } from "../../lib/teams";
10import { identity, workspaceAgents } from "../../lib/services.server";
11import { getViewer, roleIn, unwrap } from "../../lib/session.server";
12
13export function meta({ params, ...args }: Route.MetaArgs) {
14 return page(args, { title: `Org chart · ${params.owner} · g1t` });
15}
16
17export async function loader({ params, context }: Route.LoaderArgs) {
18 const viewer = getViewer(context);
19 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
20 const [directory, agents] = await Promise.all([
21 identity.peopleDirectory(viewer, params.owner).then(unwrap),
22 workspaceAgents.list(params.owner, viewer!).catch(() => null),
23 ]);
24 return { slug: params.owner.toLowerCase(), directory, agents: agents?.ok ? agents.value.map(peopleAgent) : [] };
25}
26
27export default function OrgChartPage({ loaderData }: Route.ComponentProps) {
28 const { slug, directory, agents } = loaderData;
29 const chart = orgChart(directory, agents);
30 const lines = directory.people.some((person) => person.manager);
31
32 return (
33 <div className="space-y-6">
34 {!lines && (
35 <p className="flex items-start gap-2 rounded-lg border border-line bg-surface px-3 py-2.5 text-sm text-muted">
36 <Info size={15} className="mt-0.5 shrink-0 text-faint" />
37 {directory.can_manage
38 ? "No one reports to anyone yet. Set who each person reports to under Edit profile on their profile."
39 : "No reporting lines yet. An owner sets who each person reports to on their profile."}
40 </p>
41 )}
42 <section aria-label="Reporting lines" className="overflow-x-auto rounded-xl border border-line bg-surface p-4 sm:p-5">
43 <ul className="space-y-1">
44 {chart.roots.map((root) => (
45 <Branch key={root.person.username} slug={slug} node={root} />
46 ))}
47 </ul>
48 <p className="mt-4 text-xs text-faint">Agents appear beside the person who leads their team.</p>
49 </section>
50
51 {chart.unled.length > 0 && (
52 <section aria-labelledby="unled" className="space-y-3">
53 <h2 id="unled" className="text-sm font-medium text-muted">
54 Teams no person leads
55 </h2>
56 <ul className="grid gap-3 sm:grid-cols-2">
57 {chart.unled.map(({ team, agents: teamAgents }) => (
58 <li key={team.slug} className="rounded-xl border border-line bg-surface p-4">
59 <div className="flex items-center gap-2">
60 <TeamKindMark people={team.people.length} agents={teamAgents.length} />
61 <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent">
62 {team.name}
63 </Link>
64 {team.lead?.kind === "agent" && (
65 <span className="text-xs text-faint">led by {teamAgents.find((a) => a.id === (team.lead as { agent_id: string }).agent_id)?.display_name ?? "an agent"}</span>
66 )}
67 </div>
68 <div className="mt-3 flex flex-wrap gap-2">
69 {teamAgents.map((agent) => (
70 <Link key={agent.id} to={agentPath(slug, agent.handle)} className="inline-flex items-center gap-1.5 rounded-full border border-line py-0.5 pr-2.5 pl-0.5 text-xs hover:border-line-strong">
71 <AgentPersonFace agent={agent} size={20} ring="var(--color-surface)" />
72 {agent.display_name}
73 </Link>
74 ))}
75 </div>
76 </li>
77 ))}
78 </ul>
79 </section>
80 )}
81 </div>
82 );
83}
84
85/** One person, the agents of the teams they lead, and everyone who reports to them below. */
86function Branch({ slug, node }: { slug: string; node: OrgNode }) {
87 const { person } = node;
88 return (
89 <li>
90 <div className="flex flex-wrap items-center gap-2 py-1">
91 <Link
92 to={personPath(slug, person.username)}
93 prefetch="intent"
94 className="inline-flex min-w-0 items-center gap-2.5 rounded-lg border border-line bg-bg py-1.5 pr-3 pl-1.5 transition-colors hover:border-accent/60"
95 >
96 <PersonFace person={person} size={28} />
97 <span className="grid min-w-0 text-left">
98 <span className="truncate text-sm font-medium">{personName(person)}</span>
99 <span className="truncate text-xs text-muted">{person.title ?? `@${person.username}`}</span>
100 </span>
101 </Link>
102 {node.agents.length > 0 && (
103 <span className="inline-flex items-center gap-2">
104 <span className="flex">
105 {node.agents.slice(0, 6).map((agent, index) => (
106 <Hint key={agent.id} label={`${agent.display_name} · ${agent.title || agent.role}`}>
107 <Link to={agentPath(slug, agent.handle)} className={index ? "-ml-1.5" : undefined} aria-label={agent.display_name}>
108 <AgentPersonFace agent={agent} size={22} ring="var(--color-surface)" />
109 </Link>
110 </Hint>
111 ))}
112 </span>
113 <span className="text-xs text-faint">
114 {node.agents.length > 6 ? `+${node.agents.length - 6} · ` : ""}
115 {node.leads.map((team) => team.name).join(", ")}
116 </span>
117 </span>
118 )}
119 </div>
120 {node.reports.length > 0 && (
121 <ul className="ml-[17px] space-y-1 border-l border-dashed border-line-strong pl-5 sm:pl-7">
122 {node.reports.map((report) => (
123 <Branch key={report.person.username} slug={slug} node={report} />
124 ))}
125 </ul>
126 )}
127 </li>
128 );
129}