# Sales Insights (Lead → Sales → Insights)

**Portal:** Manage · **UI:** Lead → **Sales → Insights**, first in the strip (`?stab=insights`) · **Routes:** `manage.leads.sales-insights.*` · **Gate:** the route's leads permission + object-level `LeadVisibility` · Built 2026-09-17 at the founder's request: "the sales tab needs an intelligence tab on the left of Pipeline, so it would generate analysis based on all the sub tabs information".

## What it does

One AI reading of **every other Sales sub-tab's records** — Pipeline (engagements + bookings), Membership, Appointments, Property Match, Rental Estimate, WhatsApp CTA clicks — answering what a salesperson opens the Sales tab to find out: where this person stands, what their own form answers say they want, **where the records disagree with each other**, what is missing before this can close, and the next three things to do. Every claim cites the record it came from, and each citation opens that record's own sub-tab.

It is the Sales twin of [Channel Insights](/docs/modules_handbook/shared/channel-insights/readMe.md) and shares its loop: read for free, analyse on an explicit click, reuse on an exact fingerprint, Prompt and JSON beside the reading, one row per lead in `lead_channel_insights`. What differs is the source, and that changes the reading:

| | Channel Insights | Sales Insights |
|---|---|---|
| Source | messages / transcripts — things **said** | records — things **done** |
| Citation | a message or transcript line (`wa:123`, `pc:587-2`) | a record (`eng:514`, `pm:44`) |
| Opens | the message in context | the Sales sub-tab that record lives on |
| Readiness evidence | yes, quoted from the customer | **no** — a record is not a statement, and quotes are what readiness evidence is made of |
| Figures | counted from the thread | **counted by the server** (`SalesFacts::counted()`) and handed over as facts |

**Why the name is Insights and not Intelligence** (the founder's word): the lead page's first MAIN tab is already called Intelligence (the identity / attribution overview). Two tabs with one name on one page leave the reader unable to tell which control is the parent (GUIDELINES §15), and every AI reading of a section on this page is already called Insights — Channel → WhatsApp → Insights, Zoom → Insights, Phone Call → Insights. The user chose Insights when asked. The backend keeps the same word throughout (`sales_insights`, `SalesInsights`, `/sales-insights`).

## How it works

### The fact list — `Src\Lead\Support\SalesFacts`

Renders the six areas into ONE chronology, oldest first, each line addressable:

```
[pm:44 · 2026-07-20 14:02] property match · Own stay under 700k · answers: budget: 500k-700k · area: Cheras · phone verified
[cta:65 · 2026-08-18 22:39] CTA click · cochrane_webinar · on Support Team · during session "Cochrane vs Maluri" (Property Closing)
[eng:514 · 2026-08-18 22:39] pipeline · Binastra Cochrane · stage Closer · status Booked · team: closer Kexin · booking A-12-3, SPA RM 780,000.00 · last activity 2026-09-10
```

- The prefix is the area, and `SalesFacts::TABS` maps it to the sub-tab: `eng` → pipeline, `sub` → membership, `apt` → appointments, `pm` → property-match, `re` → rental-estimate, `cta` → cta.
- **A question is always rendered beside its answer.** A property-match answer of "500k-700k" on its own tells a reading nothing about what was asked; the renderer keeps the key (or the `question` field) with the value.
- **`counted` is the server's own arithmetic** — records per tab, first / last record, days since the last one, open / won / lost pipelines, bookings, whether a membership is live NOW, appointments total and upcoming, submissions, CTA clicks. The prompt states that these are the only numbers the model may use. A model asked to count a list gets it wrong often enough that the answer has to come from here, and the panel shows these figures whether or not a reading exists.
- A blank field is rendered as blank (`outcome —`, `no estimate found`, `not verified`) — it is usually the most useful thing on the line, and it belongs in the reading's `gaps`.

### The schema — `Src\Lead\SalesInsights` (`sales-insights-v1`)

`headline`, `summary`, `momentum` (`accelerating` … `dormant`), `areas` (one `{reading, state}` per sub-tab, only the six, in the strip's order), `intent_signals`, `contradictions` (`{text, record_ids}`), `gaps`, `risks`, `opportunities`, `next_best_actions`, `suggested_message`. Caps on every list; `record_id`s must match `SalesFacts`' spelling or they are dropped. Unknown keys survive **only as strings** (the panel folds them into "More from this reading"), so a prompt an admin extends still shows, while nothing unrenderable reaches the page. It reuses `ChannelInsights`' public `str()` / `strings()` / `oneOf()` clamps rather than restating them.

**No scores.** Not for the person, not for the deal, not per area — the same rule the readiness view is built on: a number here would read as a decision the records do not support.

### Reuse and staleness

`ai_insights_hash` is `insightFingerprint(analyzer, facts hash, schema version)` — the rendered records, the provider, the model, the prompt as it resolves now, and the schema. One comparison therefore answers both questions: **Analyse** reuses the stored reading on an exact match (so the button is safe to press twice), and the panel says "the records have changed" when anything the reading was made of moved — a record added, a stage moved, a booking priced, an appointment given its outcome, a re-pinned model, an edited prompt. `conversation_ids` is empty on purpose: these records are heterogeneous, so the fingerprint is the only honest source-identity.

### Endpoints — `LeadSalesInsightsController`

`GET /manage/leads/{id}/sales-insights` (state: counted facts, records, stored reading, stale), `POST` (the one paid call), `GET …/prompt`, `GET …/download`. Shares `App\Http\Controllers\Concerns\ServesChannelInsights` with every Channel Insights controller; takes no input (`ChannelInsightsRequest`). Only `POST` calls the provider.

## The UI

`SalesInsightsTab.vue`, mounted by the Show page's Sales strip and given the strip's `open-tab` handler — the page owns the strip, the tab only says which one to open. Layout: toolbar (provenance + Prompt · JSON · Analyse) → the **counted facts** as chips that open their own tab → brief → **By area** (one card per sub-tab with its state and an "Open Pipeline" link) → **What doesn't add up** → Do now beside the message to send → what they want / missing / risks / opportunities → "More from this reading".

The read-only **lead detail modal** has no page for a paid reading's controls, so its `#tab-insights` body is a pointer to the lead page (the parity test in `useLeadTabs.test.js` requires every tab in the tree to have a body in both hosts).

## Related files

- [src/Lead/Support/SalesFacts.php](/src/Lead/Support/SalesFacts.php) · [src/Lead/SalesInsights.php](/src/Lead/SalesInsights.php)
- [app/Http/Controllers/Manage/Leads/LeadSalesInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadSalesInsightsController.php) · [Concerns/ServesChannelInsights.php](/app/Http/Controllers/Concerns/ServesChannelInsights.php)
- Prompt: [resources/prompts/sales_insights.md](/resources/prompts/sales_insights.md), registered in [config/ai_prompts.php](/config/ai_prompts.php) (`AiRequest::PROMPT_SALES_INSIGHTS`; runs on the Channel Insights model unless pinned)
- Storage: [src/Lead/LeadChannelInsight.php](/src/Lead/LeadChannelInsight.php) `CHANNEL_SALES = 7` — **not a channel**: the table is one stored reading per lead per subject, which is what this is. Account merges carry it like any other row (`LeadRepository::mergeLeadChildren`).
- Frontend: [SalesInsightsTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/SalesInsightsTab.vue), the strip entry in [useLeadTabs.js](/resources/js/composables/useLeadTabs.js), the slots in [Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) and [LeadDetailModal.vue](/resources/js/Components/LeadDetailModal.vue)
- Tests: [tests/Feature/Lead/LeadSalesInsightsTest.php](/tests/Feature/Lead/LeadSalesInsightsTest.php) (every area cited, server-counted facts handed over, reading spends nothing, reuse + staleness after a stage moves, nothing to analyse, prompt + download, visibility gate) · [SalesInsightsTab.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/SalesInsightsTab.test.js)
