# Org chart: managers, leaders, and agent assignment

> How the team manager, team leader, and agent hierarchy is stored, edited, deactivated, and filtered in the TeleQuick admin console.

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:

| Level        | Record             | Router                  |
| ------------ | ------------------ | ----------------------- |
| Team manager | `dim_team_manager` | `adminTeamManagers`     |
| Team leader  | `dim_team_leader`  | `adminTeamLeaders`      |
| Agent        | `dim_agent`        | `adminAgents` (read-only here) |
| Site         | `dim_site`         | `adminSites` (read-only here)  |

## 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](#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.

| Field           | Type             | Notes                                                              |
| --------------- | ---------------- | ------------------------------------------------------------------ |
| `id`            | `string`         | Server-assigned. Shown as `id <id>` in the edit drawer subtitle.    |
| `display_name`  | `string`         | Required. Trimmed before submit; whitespace-only input is rejected client-side with "Display name is required". Input caps at 120 characters. |
| `email`         | `string \| null` | Optional. An empty field is sent as `null`, not `""`.               |
| `site_id`       | `string \| null` | References a site from `adminSites.list`. Clearable.                |
| `user_id`       | `string \| null` | Present on the record; not editable from this screen.               |
| `active`        | `boolean`        | Editable only when editing an existing row. New rows are created active. |
| `team_manager_id` | `string \| null` | Team leaders only. The link to the owning manager.                |

## tRPC procedures behind the inline CRUD

| Procedure                       | Input                                                           |
| ------------------------------- | --------------------------------------------------------------- |
| `adminTeamManagers.list`        | `{ orgId, page, perPage, includeInactive }`                     |
| `adminTeamManagers.create`      | `{ orgId, row: { display_name, email, site_id } }`              |
| `adminTeamManagers.update`      | `{ orgId, id, patch: { display_name, email, site_id, active } }` |
| `adminTeamManagers.remove`      | `{ orgId, id }`                                                 |
| `adminTeamLeaders.list`         | `{ orgId, page, perPage, includeInactive }`                     |
| `adminTeamLeaders.create`       | `{ orgId, row: { …, team_manager_id } }`                        |
| `adminTeamLeaders.update`       | `{ orgId, id, patch: { …, active } }`                           |
| `adminTeamLeaders.remove`       | `{ orgId, id }`                                                 |
| `adminSites.list`               | `{ orgId, page, perPage }`                                      |
| `adminAgents.list`              | `{ orgId, page, perPage }`                                      |

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 and the directory link

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.

## Related

- [Authentication](/concepts/authentication) — the API key that authorises these
  control-plane calls
- [Telemetry](/platform/telemetry) — `agent_id` on CDRs, which is how call data
  joins back to `dim_agent`
