# Leads → Dashboard (team performance)

**Portal:** Manage · **UI:** Leads → **Dashboard** tab (`SectionTabs` section `leads`, first slot) · **Route:** `GET /manage/leads/dashboard` → `manage.leads.dashboard` · **Gate:** the `manage.leads.*` group's `viewLeadsAny()` + object-level `LeadVisibility` on every source · Built 2026-09-21 at the founder's request: *"introduce two tabs, one is Dashboard, one is Lead … performance kpi dashboard with visual graph or bar etc, to show how many closing this month, can filter by who & date range, how many % conversion by each sales team member, how many zoom call/phone call (in frequency or duration) by each member"*.

## What it does

How the sales team is doing for a **date range** and a **choice of people**: closings and the SPA value behind them, pipelines opened and the share that booked, and how many Zoom meetings and phone calls each member held and for how long. Every figure appears three ways — as a tile with its change against the comparison window, per member (bars and a stacked trend), and in a sortable table — so nothing on the page is readable only by colour or hover.

The Leads list itself is unchanged; it moved under the strip's second tab (**Leads**). The list's own collapsed *Team performance* panel (`PerformancePanel.vue`, the top-5 leaderboard) still exists — it is a different question ("who did the most", three ways to rank) answered in one glance on the page people open fifty times a day. This page is the full board.

## How it works

### One controller, four sources — `LeadDashboardController`

The page is one `Inertia::render('Manage/Leads/Dashboard', …)` computed per request; nothing is cached, and the whole page loads in under a second on this site's data (a month) and under two on a year. Every figure comes from a definition that already exists somewhere else, so this page cannot disagree with the page that owns it:

| Figure | Definition | Owned by |
|---|---|---|
| **Closing** | a live booking (`Booking::STATUS_ACTIVE` / `STATUS_COMPLETED`) by its **`booking_date`** (`PeriodWindow::applyDate`) | the Operations dashboard's `bookings` column |
| **Credited to** | `bookings.closer_admin_id` when set; else **every holder of the closer role** on the booking's engagement (`engagement_assignments.role = EngagementAssignment::ROLE_CLOSER`, `admin_id` is a **users.id**) | the commission split ([commission.md](/docs/modules_handbook/manage/engagement/commission.md)) — the same rows that pay the closer |
| **SPA value** | `sum(spa_price)` over those bookings, cast to float | the Booking record |
| **Pipeline opened** | an `engagements` row **created** in the range; per member, one they hold any role on | the Pipeline tab |
| **Conversion** | **cohort**: of the pipelines opened in the range, the share with a live booking **today** | this page (see *Why cohort* below) |
| **Zoom / Phone / Showroom** | `ChannelLeaderboard::countableRows()` — the founder's rules: happened, attached to a lead, not with a colleague, phone not ignored; Zoom time is the booked `duration` × 60 | [ChannelLeaderboard](/src/Operations/Services/ChannelLeaderboard.php) — the Leads list panel and the Operations board read the same method family |

**Why the closer role and not `closer_admin_id`.** On this site `bookings.closer_admin_id` is NULL on all 274 live bookings; the closer is recorded as an engagement assignment (95 rows carry the `closer` role). The column is honoured first when it *is* set, so a site that fills it gets the explicit answer. A booking with neither is still a closing for the team and is reported as `kpis.unattributed` (shown under the Closings tile when Everyone is selected — a number that vanishes teaches nobody anything). A booking with **no `booking_date`** (7 here) cannot be placed in any range and is counted nowhere on this page.

**Why cohort conversion.** Closings ÷ leads-received in the same range pairs two different cohorts (a September closing is usually a July lead) and can read above 100% on a short range. A cohort rate — pipelines opened in the range → have they booked since — cannot, and it answers the question a leader actually asks of a month ("of what we opened, how much has closed?"). Its cost is that a short recent range reads low until its pipelines mature; the page says so under the tiles. A member with **no pipelines opened has `conversion: null`**, not 0 — a rate with no denominator is unknown, and a zero would rank them below someone who converted nothing out of ten. The bars and the table both render null as a dash and sort it last.

**Team vs sum of rows.** Team figures (tiles) are **distinct records**; per-member figures credit a shared booking or pipeline to every member on it. So the sum of the member rows can exceed the tile, and the table's footer says so. With **Everyone** selected the tiles also count a closing or pipeline credited to **nobody** (it happened; it just names no closer — the Closings tile says *"includes N with no closer named"*); a record credited only to a hidden admin is not counted. With members picked, only what they are credited with counts.

### The filters — the range and who

- **Range** — `Components/RangeBar.vue` (promoted from the Operations partials on 2026-09-21 so both pages share one control) over `App\Support\PeriodWindow`: presets Today · Yesterday · This week · This month · Last month · 7 days · 30 days, plus a custom from–to (server-clamped, 366-day cap). The page **lands on This month** (`LANDING`) — the question was "how many closings this month" — and a junk `?period=` lands there too rather than on `PeriodWindow::DEFAULT` (today). Every tile's delta is against the window's comparison twin (`window.comparison_label`).
- **Who** — `Partials/Dashboard/MemberPicker.vue`: pills, one per roster member plus *Everyone*; a multi-toggle, so two people can be compared. It writes `admin[]=` (**admins.id**) to the URL. The **roster** is every admin whose user holds `Permission::SALES_EXECUTION` (the Operations rule) **plus anyone the range credits** with a closing, a pipeline or a session — a closing by someone outside the flag must not vanish — **minus admins with `admins.is_sales_dashboard_hidden`**. Every super-admin holds Sales Execution, so without that flag operators and the shared `support@` Zoom host sat on the board beside the people who sell; the founder removed Youchen Zhang, yong zhi, Support and Lee Jie on 2026-09-21. The flag is the *"Hide from the sales dashboard"* checkbox on People → Admins → Edit, and changes nothing else (role, permissions, closer rotation, visibility). A hidden admin's sessions and closings leave the board with them, and an `admin[]` id for one is ignored. `color_index` is the member's position in the id-sorted roster, so a person keeps their hue whichever subset is picked (colour follows the entity, never the rank).
- **Scope** — a viewer below `LeadVisibility::LEVEL_ALL` only counts bookings, pipelines and sessions whose **lead** they may see (`whereHas('lead', LeadVisibility::apply)`), which is the same rows their list shows. `ChannelLeaderboard::countableRows()` takes that scope as a closure.

Both filters live in the URL (`withSuite()` kept), so a view is shareable; a change re-requests the page with the other filter carried along.

### The charts (dataviz skill applied)

- **Tiles** (`KpiTile`): Closings · SPA value closed · Pipelines opened · Conversion · Zoom meetings · Phone calls, each with its delta and a one-line subtext saying what it counts.
- **Over time, by member** — `Components/Charts/AgentTrendChart.vue` (stacked daily/weekly bars, one segment per member, the tail past six folded into *Others*). ONE measure at a time: a segmented control picks Closings · Zoom meetings · Zoom time · Phone calls · Talk time — never two y-axes. The chart gained `valueKey` + `unit` props for this; its legacy `metric` pair (calls/talk) is untouched, so the Calls, F2F and Operations dashboards are unaffected. Buckets are calendar days in the display zone, **weeks past 93 days**, keyed by their start date and zero-filled.
- **Four per-member bar cards** — `Partials/Dashboard/MemberBars.vue`, horizontal, each bar in its member's hue (`agentPalette.js`, the six CVD-validated slots), direct-labelled, sorted by the measure: Closings (SPA value as the sub-label), Conversion (*n of m*), Zoom (Count ⇄ Time), Phone (Count ⇄ Time).
- **The table twin** — `Partials/Dashboard/TeamTable.vue`: every figure per member, client-sorted, with a sum-of-rows footer.

Nothing on the page reloads for a metric switch: every series carries all five measures, so the toggles are client-side.

## Related files

- [app/Http/Controllers/Manage/Leads/LeadDashboardController.php](/app/Http/Controllers/Manage/Leads/LeadDashboardController.php) — the page: `closings()` / `creditedTo()` / `pipelines()` / `roster()` / `members()` / `kpis()` / `trend()`.
- [src/Operations/Services/ChannelLeaderboard.php](/src/Operations/Services/ChannelLeaderboard.php) — `countableRows()` (+ the private `hydrate()` it shares with `forWindow()`): the sessions that count, per channel, with the `$previous` twin and the `$leadScope` closure.
- [src/Engagement/EngagementAssignment.php](/src/Engagement/EngagementAssignment.php) — `ROLE_CLOSER`.
- [app/Support/PeriodWindow.php](/app/Support/PeriodWindow.php) — the range.
- [resources/js/Pages/Manage/Leads/Dashboard.vue](/resources/js/Pages/Manage/Leads/Dashboard.vue) + `Partials/Dashboard/` (`MemberPicker.vue`, `MemberBars.vue`, `TeamTable.vue`).
- [resources/js/Components/RangeBar.vue](/resources/js/Components/RangeBar.vue) · [resources/js/Components/Charts/AgentTrendChart.vue](/resources/js/Components/Charts/AgentTrendChart.vue) · [resources/js/Components/KpiTile.vue](/resources/js/Components/KpiTile.vue) · [resources/js/Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue) (`leads` section).
- [tests/Feature/Leads/LeadDashboardTest.php](/tests/Feature/Leads/LeadDashboardTest.php) — landing range, the closer-role credit, cohort conversion, the countable-session rules, the member filter, and the visibility scope.
