| 1 | import { Info } from "lucide-react"; |
| 2 | import { Link, data } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/org-chart"; |
| 5 | import { AgentPersonFace, PersonFace, TeamKindMark } from "../../components/people"; |
| 6 | import { Hint } from "../../components/ui/hint"; |
| 7 | import { page } from "../../lib/meta"; |
| 8 | import { type OrgNode, agentPath, orgChart, peopleAgent, personName, personPath } from "../../lib/people"; |
| 9 | import { teamPath } from "../../lib/teams"; |
| 10 | import { identity, workspaceAgents } from "../../lib/services.server"; |
| 11 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; |
| 12 | |
| 13 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 14 | return page(args, { title: `Org chart · ${params.owner} · g1t` }); |
| 15 | } |
| 16 | |
| 17 | export 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 | |
| 27 | export 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. */ |
| 86 | function 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 | } |