# Operations Dashboard (Manage)

**Nav:** none — deliberately. `/manage/operations` has no sidebar entry, no hub tab and no
redirect pointing at it; it is reached by URL only until the founder has reviewed the layout
(2026-09-13). The page's `PageHeader` back link (→ Dashboard) is the way out, per GUIDELINES §15.
When it is linked, it belongs as a **`DashboardTabs` main tab** beside Insights / Agents (it is a
read-only overview, like them), with its path added to the Operations-suite `Dashboard` entry's
`prefixes` in `ManageLayout.vue`.

## Top performers — who is winning, per spoken channel (2026-09-18)

Three cards above the Sales band ([`Partials/TopPerformers.vue`](/resources/js/Pages/Manage/Operations/Partials/TopPerformers.vue),
fed by `OperationsController::topPerformers()`): **1-on-1 Zoom · Phone calls · Showroom visits**,
each naming the leader for the range with their total time and session count, then the three chasing
them. An empty channel says "Nobody in this range" — a card that shows nothing reads as a broken
card, and nobody is a finding.

**Ranked by TIME, not by session count**, and the difference is real in this data rather than
hypothetical: in a recent month one rep made 46 calls and another 35, and the one with 35 led on four
times the minutes. A count rewards dialling and hanging up; time does not. Ties break on sessions,
then on name, so the board does not reshuffle between page loads.

It reads the SAME three collections the salespeople table is built from — hydrated once per request
in `index()` — so a leader here can never disagree with their own row there. Zoom contributes
`duration` × 60: MINUTES, and what the meeting was BOOKED for, which is the figure every Zoom number
on this page already uses (no actual length is stored anywhere).

**The page now defaults to THIS WEEK** (it was Today). A performance board over a single day is
usually decided by whoever happened to take one long call, and the comparison against "yesterday to
this time" makes every figure look violent. `PeriodWindow` also gained **Last month** — the one
preset that does not end *now*, so it compares against another WHOLE month rather than a to-date
slice of one; a monthly review reading "August so far vs July so far" would be comparing a finished
month against a stump. Pinned in
[`PeriodWindowTest`](/tests/Unit/Support/PeriodWindowTest.php), including the 31st-of-the-month
overflow case.

## What it does

One page for the founder to read the company the way the business actually flows, stage by
stage (v3, 2026-09-16, after the founder's review: "structure it on the proper pipeline — FB
ads → Zoom webinar → sales; the date filter is confusing"):

- **Range bar** — ONE date control drives every number: Today / Yesterday / This week / This
  month / Last 7 days / Last 30 days, or a custom from–to. Every figure carries its change
  against the comparable window before (yesterday to the same time, last week to date, the 7
  days before, the same span before a custom range). The **only** thing outside the range is the
  **"Right now" strip** at the top — queue sizes as of this minute (WhatsApp unreplied > 1h,
  appointments to mark, conversations not followed up, leads in the pool, unreconciled payments)
  — and it says so in its own label.
- **Scoreboard** (founder-specified 2026-09-16) — four cards for the range: **Cost per lead**
  (ad spend ÷ leads from ads, delta, against a stay-under target), **Webinars** (every
  session scheduled in the range: registrants and attendees across them, the first three listed
  inline and the rest behind a "Show all" modal; the next session when the range has none), **Sales activity** (appointments booked ·
  Zoom consultations · phone calls, each with its delta; paced on appointments against a prorated
  monthly target), **New leads** (every lead created in the range, with "N from ads" alongside —
  the ads band's "Our leads" is the ad cohort only, so the two differ by design — and **Consents
  signed**: distinct leads with a SIGNED `lead_consents` row by `signed_at`, invited links excluded). Monthly targets
  are prorated onto the range ("expected" = target × hours in range ÷ hours in the month); rate
  targets (cost per lead, registrants per webinar) apply as they are. Targets are set from the
  "Set targets" button (a modal on the page) into `operation_targets`
  (`OperationTarget::METRIC_CPL / WEBINAR_REGISTRANTS / APPOINTMENTS / NEW_LEADS`; the earlier
  revenue / units / appointments_held rows are orphaned and ignored).
- Band titles carry an **info tooltip** (`Partials/InfoTip.vue`, hover / focus) instead of a
  description line; the page title and the range bar share ONE row.
- **Band 1 · Facebook ads** — spend, impressions, link clicks + CTR, CPM, Meta's lead count, OUR
  leads (first-touch registrations from those ads), cost per lead, buyers, lifetime revenue from
  those leads, ROAS; spend-per-day and Meta-leads-per-day charts; the range's top campaigns by
  spend with the same columns. All from `AdPerformanceService::rollup()` — the one ROAS
  definition on the site (the range picks the spend and the leads it bought; revenue is
  everything those leads have ever paid) — so this agrees with Traffics.
- **Band 2 · Zoom webinar** — sessions held (+ upcoming in range), registrations and attended (by
  `registered_at` / `attended_at`), no-shows, show-up rate (sessions that have RUN only — the
  Funnel dashboard's definition), average minutes watched, cost per attendee, poll answers, live
  questions, chat messages, replay viewers / finished, WhatsApp reminders reached / problems,
  buyers and revenue attributed to sessions; the next scheduled session; one row per session in
  the range with a registered→attended bar, engagement counts and attributed money.
- **Band 3 · Sales** — Zoom consultations, phone calls, showroom F2F (each with the AI score
  average and talk time), WhatsApp conversations a person wrote to, AI voice calls, memberships
  sold and their revenue, revenue split by webinar-attributed vs other; conversations per bucket
  chart (per HOUR for ranges up to 48h, per day beyond); a stage-to-stage rate list (ad lead →
  registered → attended → appointment held → unit booked); the pipeline waterfall (leads →
  assigned → appointments booked → held → units → payments); the ranked team leaderboard with
  the full per-person table in a Drawer.

## How it works

- `GET /manage/operations?period=today|yesterday|week|month|7d|30d|custom&from=Y-m-d&to=Y-m-d`
  → `Manage\Operations\OperationsController@index`, gated `permission:view-operations`;
  `PUT /manage/operations/targets` → `TargetsController@update`, gated `permission:manage-operations`
  (`Permission::VIEW_OPERATIONS` / `MANAGE_OPERATIONS`, on Roles → Sales & Marketing as
  "Operations Dashboard"). ⚠️ Both permission rows must exist:
  `php artisan db:seed --class="\RolesSeeder" --force` after deploy.
- **`App\Support\PeriodWindow`** — `resolve($key, $now, $from, $to)`: the presets above plus
  `custom` (inclusive calendar days; `from > to` is swapped; the future is clipped to today; a range
  over 366 days keeps its last 366). A window that reaches today ends NOW; "now" is rounded up to
  the next second so a row stamped this second is inside `< to`. Boundaries are computed in
  `app.user_timezone` and stored in `app.timezone`. `apply()` / `applyDate()` constrain DATETIME /
  DATE columns; `buckets()` + `bucketFormat()` give the chart its hour-or-day axis (`grain()`:
  hour ≤ 48h, else day); `hours()` is what the target proration uses; `label()` names a custom
  range by its dates.
- **`Src\Operations\Services\ChannelLeaderboard`** — the ranked team board (Zoom consultations,
  phone calls, showroom F2F), extracted 2026-09-18 so this dashboard and the **Leads list's Team
  Performance panel** cannot disagree about who is top. It owns the channel rules as well as the
  ranking — Zoom counts meetings that HAPPENED (`ZoomMeeting::hasHappened()`, the PHP twin of
  `applyHappened()`), ignored calls are excluded — so a caller that hydrated its rows differently
  still gets the same board. Metrics: `sessions` | `seconds` | `efficiency` (seconds ÷ distinct
  clients, **null** when there is no denominator, never 0).
  ⚠️ **Only work attached to a lead counts** (founder's rule): a session with no `lead_id` earns no
  sessions, no time and no client — it is still reported as "N not counted — no lead linked", with
  `client_rows` / `unlinked_rows` carrying the names and links behind both figures.
  ⚠️ **An internal session counts for nobody either** (founder's rule, 2026-09-18): a Zoom, call or
  visit whose lead is a COLLEAGUE — anyone on Manage → People → Admins, i.e. an `admins` row or the
  `is_staff` flag `UserRepository` sets on an admin's own lead facet — is dropped from sessions,
  time and clients, and reported as "N internal" (`internal` / `internal_rows`). Both rules are
  documented where they are read most, on the [Leads list](/docs/modules_handbook/manage/leads/readMe.md).
- **Scoreboard sources** — cost per lead from the ads band's totals; webinars from the same
  `sessionsIn()` query the webinar band uses (computed once per request); appointments by
  `appointments.created_at`; Zoom consultations / phone calls from the hydrated recording rows;
  leads = `leads.created_at`, ad share = the ads band's first-touch lead count.
- **Ads** — `AdPerformanceService::rollup('campaign', from, to)` for the range and its comparison;
  totals re-derived from the summed rows (rates never averaged); `meta_ad_insights` per day for the
  charts (DATE grain, so a one-day range has one bar and the charts hide).
- **Webinar** — `events` with `scheduled_date` in range (not cancelled), `withCount` registrations /
  attended / no-show and `withAvg(attended_minutes)`; the webinar relation (`latestOfMany`) is
  loaded WITHOUT a column list — a limited select on it is ambiguous; engagement per
  `zoom_webinar_id` from `zoom_webinar_responses` (poll + quiz kinds), `zoom_live_questions`,
  `zoom_webinar_chats`; money per `purchase_histories.event_id` (what `PurchaseSessionAttributor`
  stamped); replay = distinct people in `funnel_video_views` by `last_seen_at` (lead or visitor
  key, never rows); reminders = `funnel_whatsapp_sends` by `created_at` (SENT vs FAILED + SKIPPED).
- **Sales** — the three recording channels are hydrated ONCE for the window with a narrow column
  list (the AI score lives in `ai_analysis` JSON); WhatsApp = distinct 1:1 non-test conversations on
  visible non-sandbox channels with ≥1 outbound message a PERSON wrote (`created_by IS NOT NULL`,
  `is_historical` excluded); AI calls exclude the web-test lane and refused calls; memberships =
  `member_subscriptions` ACTIVE by `paid_at` (DATE), revenue default currency; revenue split by
  `purchase_histories.event_id` null / not null. Activity buckets by `DATE_FORMAT()` in the stored
  zone (= display zone, the Insights assumption). Team roster = admins holding
  `Permission::SALES_EXECUTION`, zeros kept; two id spaces (`admins.id` on recordings, `users.id`
  everywhere else) travel with each row for the drill-throughs.
- **Right now** — each counter uses the predicate of the list it links to
  (`WhatsappConversation::unreplied()` shared with the inbox; the AE list's `needs_marking`;
  `needsFollowUp()` over the last 30 days; `PaymentWebhookEvent::unmatched()`).
- Nothing is cached (house style). A group-scoped admin granted the permission sees
  lead-visibility-narrowed lead / appointment / booking figures but company-wide ads, webinar,
  recordings and WhatsApp.

## Related files

**Backend**
- `app/Support/PeriodWindow.php` — the resolver
- `app/Http/Controllers/Manage/Operations/OperationsController.php`, `TargetsController.php`
- `app/Http/Requests/Manage/Operations/Targets/UpdateRequest.php`
- `src/Operations/OperationTarget.php`, `src/Operations/Repositories/OperationTargetRepository.php`, `src/Operations/Facades/OperationTargetRepository.php`
- `app/Services/Marketing/AdPerformanceService.php` (ROAS — read, not owned)
- `src/Auth/Permission.php` (`VIEW_OPERATIONS`), `app/Http/Controllers/Manage/People/RolesController.php` (label)
- `src/Whatsapp/WhatsappConversation.php` — `scopeUnreplied()` (shared with `Manage\Whatsapp\InboxController::applyUnrepliedFilter()`)

**Frontend**
- `resources/js/Pages/Manage/Operations/Index.vue`
- `resources/js/Pages/Manage/Operations/Partials/RangeBar.vue`, `StatStrip.vue`, `ScoreCard.vue`, `CampaignsTable.vue`, `SessionsTable.vue`, `TeamLeaderboard.vue`, `TargetsModal.vue`, `SalespeopleTable.vue`, `AttentionTile.vue` (kept for reuse; the strip renders its own pills)
- `resources/js/Components/FunnelMetrics/ConversionWaterfall.vue`, `Components/Charts/AgentTrendChart.vue` (gained an optional `labels` prop for hourly buckets), `Components/TrendChart.vue` (moved out of Insights/Partials), `Components/Drawer.vue` (reused)
- `resources/js/Components/KpiTile.vue` + `Sparkline.vue` (moved out of `Pages/Manage/Insights/Partials/` — shared with Insights)

**Migrations** — `database/migrations/2026_09_14_000001_create_operation_targets_table.php`

**Routes** — `routes/web.php` → `manage.operations.index`, `manage.operations.targets.update`

**Tests** — `tests/Unit/Support/PeriodWindowTest.php`, `tests/Feature/Manage/Operations/OperationsDashboardTest.php`
