The Org chart screen (/admin/org-chart) is the editing surface for the three-level people hierarchy that the rest of the TeleQuick admin console reads from. It renders one card per team manager, a grid of team-leader cards inside it, and agent counts rolled up from the agent dimension. Everything on the screen is backed by three dimension tables and four tRPC routers:

The three levels and what owns what

The hierarchy is TM → TL → agents, and each link is a single nullable foreign key on the child row:
  • A team leader points at its manager with team_manager_id. If that column is null, the leader has no manager.
  • An agent points at its leader with team_leader_id. The org chart never writes this column — it only reads it to count agents.
There is no membership join table. Reassignment is therefore a single-column update on the child, not an operation on the parent. Deleting or deactivating a parent does not cascade to children (see Deactivation is a soft delete). The page header summarises the whole org, not the filtered view: total team managers, total team leaders, and the total returned by adminAgents.list.

Record fields

Team managers and team leaders share the same shape; a team leader adds one column.

tRPC procedures behind the inline CRUD

Every procedure is scoped by orgId, taken from the active org in the tenant context. If no org is active, the screen issues no queries at all. List procedures return { rows, total }. The org chart loads the hierarchy in one shot rather than paginating: it requests up to 200 sites, 500 team managers, 500 team leaders, and 5000 agents. Sites are cached for 60 s and agents for 30 s; manager and leader lists are not cached, so they re-fetch on mount. The manager and leader lists are requested with includeInactive: true, which is why deactivated people still appear on the chart with an Inactive chip. After a successful create, update, or remove, the drawer invalidates the corresponding list query (adminTeamManagers.list or adminTeamLeaders.list) and closes. Agent counts refresh on their own cache schedule.

Creating and reassigning team leaders

There are three entry points into the team-leader form, and they differ only in what they prefill:
  1. New TL in the page header — opens an empty form with no site and no manager. Submitting it creates a leader with team_manager_id: null, which lands in the unassigned bucket.
  2. Add TL on a manager card — prefills team_manager_id with that manager’s id and site_id with that manager’s site. Both remain editable before you save.
  3. Clicking a team-leader card — opens the same form loaded with that leader’s row, in update mode.
Reassigning a leader to a different manager is an adminTeamLeaders.update with a new team_manager_id. Because the grouping is derived from that column on every render, the leader’s card moves to the new manager’s card as soon as the list query is invalidated. Agents follow their leader automatically — their team_leader_id is untouched, so their counts move with the card. The distinction between create and update is made by whether the row carries an id. The “Add TL” path passes a synthetic row with id: '', which the form treats as a create.

Deactivation is a soft delete

The remove procedures on both routers are soft deletes. The confirm dialog and the success toast both say Deactivate / Deactivated, not “delete”, and the row stays queryable via includeInactive: true. For a team manager, the confirmation spells out the effect on children:
Soft-deleted. Their TLs stay but lose the team_manager_id link until reassigned.
So deactivating a manager:
  • keeps every team-leader row intact and active,
  • clears the team_manager_id link on those leaders, which moves them into the Unassigned team leaders card,
  • leaves agents untouched — they still point at the same team leaders.
Deactivating a team leader marks that leader inactive. Because inactive leaders are still listed, the leader’s card keeps rendering under its manager with an Inactive chip and its agent counts still show. Reactivation does not need a separate procedure: open the row and tick Active, which sends active: true in the update patch. The Active checkbox is only rendered when editing an existing row.

Sites filtering and unassigned leaders

The single filter on the screen is a Site chip combobox, populated from adminSites.list (label = site name, hint = site code). Clearing it returns to All sites. The filter is applied client-side and independently at both levels:
  • Team managers are kept when tm.site_id === filterSite.
  • Team leaders are kept when tl.site_id === filterSite.
Because the two checks are independent, a leader at a different site from its manager disappears from that manager’s card while the filter is active, and a manager at a filtered-out site hides its whole card even if its leaders match. Site codes are shown as a chip on the manager row; a manager with no site shows — in that position. Unassigned team leaders is a synthetic card, not a record. It collects every leader whose team_manager_id is null (after site filtering) and renders only when that bucket is non-empty. It has no avatar, no edit button, and no tags — there is no manager row to attach them to. The fix is stated inline on the card: edit each leader and assign a manager. Agents whose team_leader_id is null are bucketed separately when counts are computed, but that bucket has no card on this screen, so those agents appear in the header total and in no per-card count.

Tagging team managers

Team managers are taggable with the shared tag system, under the resource kind dim_team_manager. Tags render as a TagCell on each manager row and can be added or removed inline. Tag resolution is deliberately batched: the screen calls useResourceTags once for the ids of all currently visible managers and passes each card its slice. Cards only write. A write calls the hook’s refresh, which re-resolves the whole visible set rather than one row. Team leaders and agents are not tagged from this screen. Agent counts are derived, never stored on the leader. The screen pulls adminAgents.list and buckets rows by team_leader_id, splitting each bucket by the agent’s kind:
  • kind: 'human' increments the human count,
  • anything else increments the LLM count.
A team-leader card always shows <n> human. The LLM chip renders only when that leader has at least one LLM agent. The manager card shows the sum of both kinds across its leaders, alongside the leader count. Each team-leader card carries a View → link to /admin/directory?tl=<team_leader_id>. This is the drill-down path from the structural view to the per-agent roster: the org chart tells you who owns whom, and the directory, filtered by that tl query parameter, tells you who is on that team.
  • Authentication — the API key that authorises these control-plane calls
  • Telemetry — agent_id on CDRs, which is how call data joins back to dim_agent