# Reports (Manage · CEO suite)

**Portal:** Manage · **Suite:** `ceo` · **Routes:** `manage.ceo.reports.*` · **Nav:** Reports (one sidebar entry; a report's Preview / History are tabs on its own page) · **Permission:** `view-ceo-dashboard` · **Commands:** `reports:send-due` (every 5 min) · `reports:send {key}`

Part of the [CEO Dashboard](/docs/modules_handbook/manage/ceo-dashboard/readMe.md) suite. Built 2026-10-02 at the founder's request: *"ask ai to summarize the performance of each of u and email to me … make it weekly update on every Friday 11pm"*, with the owner's follow-up that the same module will be asked to report **other things later** — so it is built as a frame every report plugs into.

## What it does

Reports the system **sends by itself**, on a schedule, to a list of people, through any of three channels.

- **Reports list** (`/manage/ceo/reports`) — one card per report: what it covers, when it goes, through what, to whom, and how the last send ended.
- **A report's page** (`/manage/ceo/reports/{uuid}`):
  - **Preview** — the report exactly as it would be sent *this minute*, from today's data. This is the answer to "what will be reported?".
  - **History** — every send so far: when, why (scheduled / sent by hand / test), how it ended, who got it through which channel and why a delivery was skipped — and **View**, the document as it actually went out that day.
  - **Settings** (modal) — on/off, day + time, channels, recipients.
  - **Send me a test** (to the acting admin only) and **Send now** (to the whole list).
- **One report today:** *Weekly sales performance* — see [its section](#the-weekly-sales-performance-report-sales-weekly) below.

A report is **OFF until somebody switches it on** — a deploy never starts mailing by itself.

## How it works

### The split: WHAT is code, the rest is shared
| | Where | Who decides |
|---|---|---|
| **What a report says** | one class — a [`ReportDefinition`](/src/Report/Contracts/ReportDefinition.php), listed in [config/reports.php](/config/reports.php) | a developer |
| **When / to whom / through what** | `scheduled_reports` + `scheduled_report_recipients` | an admin, on the page |
| **How it is delivered** | [`ReportChannel`](/src/Report/Contracts/ReportChannel.php) classes, also listed in config | a developer (once per channel) |
| **What happened** | `scheduled_report_runs` | the system |

A definition returns a [`ReportDocument`](/src/Report/ReportDocument.php): a title, a period, and **sections** — each a small table whose first column names the row — plus notes and the raw numbers. Every channel and the page render that same document, so no channel knows which report it is carrying, and a new report needs **no new email template, page or Mailable**.

**The look is shared too (2026-10-02, owner: "put colors, make this report very clear").** A document carries only HINTS — an emoji `icon` on a section or column, a `tone` on a column (`good` = green, `bad` = red), `highlights` (the headline tiles), `_rank` on a row (a cell's place in its column — 1st / 2nd / 3rd, set by `BaseReport::markRanks()`: a zero is never ranked, a tie shares its place), and a section `legend` (`BaseReport::explain()` — "how each number is counted", one plain sentence a term, because the owner's first question on seeing the report was *"Meaningful / Came to webinar depends what?"*). The email ([scheduled-report.blade.php](/resources/views/emails/reports/scheduled-report.blade.php)) and the page ([ReportDocument.vue](/resources/js/Pages/Manage/Ceo/Reports/Partials/ReportDocument.vue)) turn them into one visual language: a zero is faded, each column's top three carry 🥇 🥈 🥉 (1st in a green pill), a non-zero `bad` value is a red pill, the totals row is navy, and the legend is shown **on the page only**, in a grey box under its table, collapsed until opened. The email does not print it (owner, 2026-10-02): no mail client can reliably fold it away — Gmail and Outlook drop `<details>` and show it open — so it sat between the tables. Anything a reader of the EMAIL must know to trust a number therefore goes in `notes`, never only in the legend. Telegram prints the icons beside each label and a 🥇 on each column's winner only. A renderer that cannot show them (a WhatsApp template value) ignores them — so a report must never depend on colour to be understood; a zero that needs explaining belongs in `notes`. The email uses inline styles only (mail clients drop `<style>`).

### Sending — [`ReportRunner`](/src/Report/Services/ReportRunner.php)
One send = one `scheduled_report_runs` row, opened as *Sending…* and closed with what went out:
1. `build()` the document.
2. Ask the AI for 3–5 sentences (`summaryPrompt()`), handing it the finished numbers — it describes, it never calculates. A failure sends the report without it.
3. Resolve recipients — the saved list, or for a **test** only `target_user_id`.
4. For each channel the report has ticked: if `unavailableReason()` is not null, write ONE *skipped* delivery row saying why; else `send()` and collect a row per recipient (`sent` / `queued` / `skipped` / `failed`).
5. Close the run — `ScheduledReportRun::statusFor()`: nothing delivered → *Nothing sent* (or *Failed* if something was tried); some failed → *Partly sent*; else *Sent*. The document, the summary and every delivery row are stored.

It never throws: whatever goes wrong becomes the run's status and error.

### Scheduling — exactly once
`reports:send-due` ([SendDueReports](/app/Console/Commands/SendDueReports.php)) runs **every five minutes**. For each report that is on it asks `ScheduledReport::dueSlot()`:
- the latest scheduled moment at or before now (day + time are in the DISPLAY timezone, `app.user_timezone`);
- still inside the **grace window** (`reports.grace_hours`, 6) — a scheduler that was down at 11pm still sends when it comes back, but a report found a day late is not sent;
- and **after `schedule_changed_at`** — switching a report on at Saturday 1am must not send Friday's 11pm slot, and moving the time must not send twice in one week. The repository stamps that column whenever on/off, day or time changes.

The slot is then **claimed by inserting the run row**: `UNIQUE(scheduled_report_id, slot_at)` makes the second of two overlapping schedulers fail the insert (`start()` returns null) — the guarantee is the index, not a lock or a cache key. The same command closes any run still *Sending…* after `reports.stale_minutes` (no queue worker picked it up).

A test / "send now" from the page is queued ([RunScheduledReport](/app/Jobs/Report/RunScheduledReport.php), one attempt — a retry would mail everybody twice); the History tab re-reads itself every 5 s while a run is open. A second click within two minutes of an unfinished send is refused.

### Channels
| Key | Class | Reaches | Notes |
|---|---|---|---|
| `email` | [EmailReportChannel](/src/Report/Channels/EmailReportChannel.php) | everyone on the list, incl. outside addresses | [ScheduledReportMail](/app/Mail/ScheduledReportMail.php) — the full tables. One message per address. |
| `telegram` | [TelegramReportChannel](/src/Report/Channels/TelegramReportChannel.php) | staff with a personal chat on Setting → Notifications | `Notifier::sendToUsers('ceo.scheduled_report')` — **addressed, never a shared group**: a report names people's numbers. A recipient with no chat / event unticked is a *skipped* row saying so. See [Notify](/docs/modules_handbook/shared/notify/readMe.md). |
| `whatsapp_cloud` | [WhatsappCloudReportChannel](/src/Report/Channels/WhatsappCloudReportChannel.php) | staff with a phone on their profile | Meta **Cloud** number only, as an APPROVED template (Meta allows nothing else to open a conversation). Unavailable — with the reason — until the report's template is approved AND synced into this system (Messages → Templates → *Sync from Meta*). The contact is linked to the colleague's own account before the thread opens, so it never fabricates a lead. The thread shows in the shared Inbox. |

**Recipients** are either a **staff account** (reachable on every channel) or an **outside email** (email only). Outside phone numbers are deliberately not supported: opening a WhatsApp thread for an unknown number hands it to the lead linker.

> **Planned:** a WhatsApp **QR / Bridge** channel (free text, no template). It is one more `ReportChannel` class + one config line — no report, page or table changes. The owner has also said the [Notify](/docs/modules_handbook/shared/notify/readMe.md) service should gain the same transport.

## Reference usage — adding a report

Three steps, nothing else:

**1. One class** extending [`BaseReport`](/src/Report/Definitions/BaseReport.php) (the working example is [`SalesWeeklyReport`](/src/Report/Definitions/SalesWeeklyReport.php); the smallest possible one is `TinyReport` at the bottom of [ScheduledReportTest](/tests/Feature/Report/ScheduledReportTest.php)):

```php
class ClosingBoardReport extends BaseReport
{
    public function key(): string { return 'closing-board'; }          // stable — never rename once shipped
    public function name(): string { return 'Property closing board'; }
    public function description(): string { return 'Webinars planned vs held, show-ups and bookings.'; }
    public function defaultSchedule(): array { return ['day' => ScheduledReport::DAY_SUNDAY, 'time' => '23:00']; }

    public function build(?CarbonImmutable $now = null): ReportDocument
    {
        return new ReportDocument(
            title: $this->name(), period: '…', from: '2026-10-05', to: '2026-10-11', generatedAt: '…',
            sections: [[
                'key' => 'webinars', 'title' => 'Webinars',
                'columns' => [$this->column('name', 'Project'), $this->column('held', 'Held')],
                'rows' => [['name' => 'Peel Lane', 'held' => '2']],   // cells are display STRINGS
                'total' => null, 'empty' => 'No webinar this week.', 'footnote' => null,
            ]],
            notes: [], url: url('/manage/…'), urlLabel: 'Open the board',
            data: [/* the raw numbers — what the AI summary and week-over-week read */],
        );
    }
}
```

**2. One line** in `config/reports.php` → `definitions`.

**3. Deploy.** The report appears on the Reports page by itself (`ScheduledReportRepository::ensure()` creates its settings row the first time the page is opened): switched **off**, on the schedule the class suggests, email ticked. No migration.

Optional, each one method on the class:
- **AI summary** — `summaryPrompt()` returns an `AiRequest::PROMPT_*` key (register it per the [AI handbook](/docs/modules_handbook/shared/ai/readMe.md)). The prompt receives `ReportDocument::$data` + `period` + `notes` and must answer `{"summary": "…"}`.
- **WhatsApp** — `whatsappTemplate()` names an approved template and `whatsappParameters()` returns its `{{n}}` values in order. Rules that come from Meta, not from us: a value **cannot contain a line break** (people share one line, joined with ` • ` — `ReportText::templateValue()` cleans each value), and the count must equal the template's — the channel checks it and fails the channel once rather than once per recipient. Author a **body-only** template (no header / footer / button): only then may the filled-in body run past 1,024 characters.

## The weekly sales performance report (`sales-weekly`)

[`SalesWeeklyReport`](/src/Report/Definitions/SalesWeeklyReport.php) — default Friday 11pm, the last 7 days.

- **No pick-up (owner, 2026-10-02) — the ONE place it differs from the page.** A call whose recording held no speech at all is not a conversation: it is taken out of *Calls*, *Call time* and *Leads engaged* and shown in its own **No pick-up** column. The rule is `CallRecording::isNoSpeechError()` — the pipeline failed and every transcription service that ran answered "empty transcript" (on production: ~1-minute badge recordings of a call nobody answered). A timeout or a bad key is NOT a no pick-up — that is "we do not know". Nothing is stored for it; it is read off `pipeline_error`, and [FallbackTranscriberTest](/tests/Feature/Transcription/FallbackTranscriberTest.php) pins the rule to the error text that class really writes. The Sales Engagement page still counts those rows as calls, so its Calls / Leads can be higher than the report's. `SalesWeeklyReport::sessions()` does the split from the page's own rows (`ChannelLeaderboard::countableRows()`).
- **Everything else is the page's number, untouched.** The per-person figures are [Sales Engagement](/docs/modules_handbook/manage/ceo-dashboard/employee-performance/readMe.md)'s own (`SalesEngagementReport` over `PeriodWindow::LAST_7`, the two saved teams, **unscoped** — the whole business, not one viewer's leads), so the report and that page cannot disagree. Read that handbook for what *meaningful*, *came to webinar*, *paid* and *sales Zoom* mean.
- **Zoom time** — `zoom_meetings.duration` is the BOOKED length until the recording sync overwrites it with the length Zoom recorded; there is no other "actual duration" column. A person whose total includes a Zoom with no `zoom_recordings` row gets a `*` and a note.
- **Post-VP** — the founder's KPI for the handover person is *"confirmation of status in system, how many key collected"*: `booking_stages` marked DONE in the window, credited to whoever marked it (`updated_by`, else `pic_admin_id`), plus how many were the key-collection stage.
- **Notes** — every zero that is a setup gap rather than a result is said out loud: no webinar ticked as a property sales webinar, unanalysed talks, no unit started on the Post-VP board.
- **AI** — prompt `ceo_weekly_sales_report` (Flash tier), body in [resources/prompts/ceo_weekly_sales_report.md](/resources/prompts/ceo_weekly_sales_report.md).
- **WhatsApp template** — `weekly_sales_report` (en_US), submitted to Meta 2026-10-02 as UTILITY on the "Cloud API" number's WABA. Seven values: 1 from · 2 to · 3 summary · 4 team total · 5 by salesperson · 6 closers · 7 post-VP. A real week fills it to ~1,400 characters — past the 1,024 a template with a header, footer or button may carry, which is why it is body-only; this has not been proven by a real send yet. It was submitted from a development machine, so production only learns of the row through *Sync from Meta* (`whatsapp:sync-templates` is not scheduled).

## The property closing tracking board (`property-closing-weekly`)

[`PropertyClosingWeeklyReport`](/src/Report/Definitions/PropertyClosingWeeklyReport.php) — default **Sunday 11pm**, the last 7 days. Built 2026-10-05 from the founder's request (property-closing half only; Post-VP, renovation and targets come later as their own report).

- **Sessions** — every PROPERTY SALES webinar session that started in the week, by the ONE shared rule [`PropertySalesWebinars::ids()`](/src/Zoom/Support/PropertySalesWebinars.php) (also read by the Sales Engagement page and the Friday report): its **funnel is ticked "Property sales funnel"** on Events → Funnels → Edit (`event_funnels.is_property_sales`, owner 2026-10-05 — the easy way: every session of the funnel counts, nothing to tick per session), OR the webinar is ticked on the Sales Engagement picker (for webinars in no funnel), OR it is a session of a ticked series (`ZoomWebinar::seriesName()`). *Held* = Zoom reported it ended, or anybody attended; cancelled / not held are listed.
- **Show-ups** — distinct leads who attended ≥ `WebinarHistory::ATTENDED_SECONDS` (60 s); colleagues (`leads.is_staff`, or a lead whose user is an admin) excluded.
- **Bookings** — paid project-fee pipelines (`PaidPipelines`) whose payment fell in the week: the RM100 booking fee and its siblings (RM299 on some projects) — the amount is shown per row.
- **Who brought them (owner's rule, 2026-10-05)** — credited to every member of the sales ENGAGEMENT team who had a meaningful talk (`MeaningfulConversation`, 1-1 Zoom or call, any time) with that lead BEFORE they showed up / paid. Both are credited when two did, so per-person rows can exceed the total; leads nobody talked to first get their own *No sales talk before* row (never ranked). *Closed* = that booking's pipeline is now Booked or beyond. The booking list has no Closer column (removed at the owner's request, 2026-10-05).
- **Booking list** — numbered, the EARLIEST payer first (owner, 2026-10-05). A table whose first column is labelled `#` is a numbered list: `ReportText::rowLine()` prints it as "1. Name · …" in text channels.
- **Sales pipeline (owner, 2026-10-05)** — per project: pipelines ADDED this week (`engagements.created_at`) and pipelines LOST this week (`status = LOST` with `lost_at` in the week), plus a numbered "lost this week" list with the reason typed by staff. The two are separate questions — a pipeline lost this week may have been added months ago. Two tiles on top: *Added to sales pipeline*, *Lost from the pipeline*.
- **Email layout** — a cell over 40 characters (a lost reason) wraps left-aligned instead of staying on one line, so free text never widens the email.
- **AI** — prompt `ceo_property_closing_report` (Flash), body in [resources/prompts/ceo_property_closing_report.md](/resources/prompts/ceo_property_closing_report.md). No WhatsApp template yet (the channel reports itself unavailable).
- Pinned by [PropertyClosingWeeklyReportTest](/tests/Feature/Report/PropertyClosingWeeklyReportTest.php) (credit only for a talk BEFORE the show-up; an unticked session of a ticked series counts).

## Status and what is still owed (as of 2026-10-03)

Read this before extending the module — it is the part the code cannot tell you.

- **Live.** Deployed 2026-10-02; the first scheduled send went out Fri 2 Oct 23:01 (run #2, email to Support + Waikit, status Sent). Only the `email` channel is ticked on production; Telegram is not. The owner is not on the recipient list.
- **WhatsApp — waiting on Meta.** Template `weekly_sales_report` (en_US) is PENDING on the production WABA (meta id 1106875385456713). When approved: Messages → Templates → *Sync from Meta* on production, then tick WhatsApp in the report's settings. Its filled-in body is ~1,400 characters; body-only templates are allowed past 1,024, but this has not yet been proven by a real send.
- **The Sunday board's property-closing half is BUILT (2026-10-05, above); its Post-VP / renovation / targets half is NOT.** The original request, for reference: The founder's request (2026-10-02), verbatim as far as it goes: *property closing* — webinar sessions scheduled in the system vs actually conducted, audience show-ups broken down by which sales team member brought them (classified as meaningful discussion), RM100 bookings broken down by who brought the audience; *Post VP* — webinar plan vs actual, existing-owner show-ups, RM9.90 rental + renovation analysis payments (the Post VP webinar's CTA), key collection, reno closing; *targets* — Peel Lane 2 sessions / 5 closings, Post VP 1 session / 5 closings, tour video/drone for Bangsar (Oct), OKR (Oct), KLCC (Nov), Maxim key collection 100%. It is one more `BaseReport` class (see *Reference usage*). Data the system does NOT hold yet for it: a "Post VP" webinar type (only the `is_property_sales` tick exists), a marker for the RM9.90 product (find it by `payment_links.amount` / title), a status-change history (only the last `updated_by`), and any tour-video progress field.
- **Known blind spots in the weekly report:** *Paid* is "paid AFTER the first meaningful talk", so a lead who pays at the webinar first and is called afterwards lands in the closer table's *Deals*, not here — the AI summary has already once written "0 paid leads" on such a week; *Meaningful* undercounts while calls are still being transcribed; the no pick-up rule is read off `pipeline_error` text (pinned by a test, but a reworded error would silently stop it).
- **Mail arrived from the Gmail BACKUP on 2026-10-02 even though the primary logs in fine** — see the [Email handbook](/docs/modules_handbook/shared/email/readMe.md): the hand-over is logged as `Transport "smtp://smtp.mailgun.org:2525" failed.`, and `php artisan mail:check` diagnoses it. Unresolved at the time of writing.

## Data model

**`scheduled_reports`** — `uuid`, `report_key` (unique), `is_enabled`, `day_of_week` (0 Sunday … 6 Saturday), `send_time` (`HH:MM`), `channels` (json list of channel keys), `schedule_changed_at`, blame. One row per report; never deleted.

**`scheduled_report_recipients`** — `scheduled_report_id`, `user_id` (staff) **or** `email` + `name` (outside), `position`, blame. Saved as the WHOLE list every time.

**`scheduled_report_runs`** — `uuid`, `scheduled_report_id`, `trigger` (1 scheduled · 2 sent by hand · 3 test), `status` (1 sending · 2 sent · 3 partly sent · 4 failed · 5 nothing sent), `slot_at` (null for manual / test), `target_user_id` (a test), `period_from` / `period_to`, `summary`, `document` (the snapshot), `deliveries` (`[{channel, to, name, status, error}]`), `error`, `started_at` / `finished_at`, blame. **`UNIQUE(scheduled_report_id, slot_at)`**.

## Related files

**Backend**
- Contracts: [ReportDefinition](/src/Report/Contracts/ReportDefinition.php) · [ReportChannel](/src/Report/Contracts/ReportChannel.php)
- [ReportDocument](/src/Report/ReportDocument.php) · [ReportRecipient](/src/Report/ReportRecipient.php) · [ReportText](/src/Report/Support/ReportText.php) (text rendering, `duration()`, `templateValue()`)
- Models: [ScheduledReport](/src/Report/ScheduledReport.php) (`latestSlot` / `nextSlot` / `dueSlot`) · [ScheduledReportRecipient](/src/Report/ScheduledReportRecipient.php) · [ScheduledReportRun](/src/Report/ScheduledReportRun.php) (`statusFor`)
- Repositories (+ facades): [ScheduledReportRepository](/src/Report/Repositories/ScheduledReportRepository.php) (`ensure` · `update`) · [ScheduledReportRunRepository](/src/Report/Repositories/ScheduledReportRunRepository.php) (`start` · `finish` · `failStale`)
- Services: [ReportRegistry](/src/Report/Services/ReportRegistry.php) · [ReportRunner](/src/Report/Services/ReportRunner.php)
- Channels: `src/Report/Channels/{Email,Telegram,WhatsappCloud}ReportChannel.php`
- Definitions: [BaseReport](/src/Report/Definitions/BaseReport.php) · [SalesWeeklyReport](/src/Report/Definitions/SalesWeeklyReport.php)
- [ReportsController](/app/Http/Controllers/Manage/Ceo/ReportsController.php) (`index` · `show` · `update` · `test` · `send`) · [UpdateRequest](/app/Http/Requests/Manage/Ceo/Reports/UpdateRequest.php)
- [RunScheduledReport](/app/Jobs/Report/RunScheduledReport.php) · [SendDueReports](/app/Console/Commands/SendDueReports.php) · [SendReport](/app/Console/Commands/SendReport.php) (`reports:send sales-weekly --dry-run` prints it and sends nothing) · the schedule entry in [Kernel.php](/app/Console/Kernel.php)
- [ScheduledReportMail](/app/Mail/ScheduledReportMail.php) · `resources/views/emails/reports/scheduled-report{,-text}.blade.php`

**Frontend**
- `resources/js/Pages/Manage/Ceo/Reports/{Index,Show}.vue` + `Partials/{ReportSettingsModal,ReportDocument}.vue` + `Partials/Tabs/{PreviewTab,HistoryTab}.vue`
- [ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) (`ceoNav`: Reports) · the *Weekly report* link on [SalesEngagement.vue](/resources/js/Pages/Manage/Ceo/EmployeePerformance/SalesEngagement.vue)

**Config** — [config/reports.php](/config/reports.php) (`definitions`, `channels`, `grace_hours`, `stale_minutes`, `notify_event`) · [config/notify.php](/config/notify.php) (`ceo.scheduled_report`) · [config/ai_prompts.php](/config/ai_prompts.php) (`ceo_weekly_sales_report`)

**Migrations** — `2026_10_02_210000_create_scheduled_reports_tables` · `2026_10_02_210100_backfill_scheduled_report_subscriptions` (ticks `ceo.scheduled_report` on every existing personal Telegram destination — safe, since the event is addressed)

**Routes** (`routes/web.php`, CEO group) — `manage.ceo.reports.{index,show,update,test,send}`; `test` and `send` are throttled (6/min).

**Tests** — [ScheduledReportTest](/tests/Feature/Report/ScheduledReportTest.php): a due report is sent once and keeps its snapshot; switching on does not send the slot behind it; a test goes to the acting admin only; the weekly sales report fills all seven template values; settings save the whole recipient list and both pages render.
