# Leads (Manage)

**Portal:** Manage · **Routes:** `manage.leads.*` · **Nav:** "Leads"

## What it does
Manages the people captured by the public [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) funnels. A **lead is a person** (one per account); each funnel/program they register for is a **registration row** carrying its **own first-touch ad/source attribution**. So one person who registers for two funnels is **one lead with two registrations** (it shows under both funnels' Leads). Admins list/search/filter, change status, view per-registration attribution, edit contact details, and delete.

> A lead row stores **only** `user_id` + `flg_lead_id` + `status` (the person). **Attribution + the funnel live on `lead_funnels`** (one row per `(lead, funnel)`, `unique`). Name / email / phone live on the linked `User` + `UserProfile` (single source of truth).

## How it works

### `LeadLinker` — the unified linking shell (2026-07-17)
The gate below unifies **who-is-who**. Everything *around* it — whether a phone may be trusted as a key,
what attribution is recorded, what outcome is reported — is unified by the shared **`LeadLinker`** service,
which wraps this gate and never reimplements it. **Any code that links or creates a lead should go through
it**, and its handbook is the one to read first:

> **→ [Lead Linking (`LeadLinker`)](/docs/modules_handbook/shared/lead-linking/readMe.md)** — the service, the
> phone-trust policy (**the account-takeover boundary**), the outcome vocabulary, attribution, and a
> *Reference usage* pointing at a real consumer.

> **Username-era note (2026-07-22):** a WhatsApp contact whose phone is HIDDEN (an `@lid` / BSUID-only
> contact — see the [WhatsApp LID readiness](/docs/modules_handbook/manage/messages/whatsapp/group.md) section)
> is **UNLINKABLE by design**: the identity gate needs an email or phone, so no lead is created and no
> RBAC assignment happens until the phone is learned — at which point the normal link paths (live
> `considerLinkContact`, the hourly `whatsapp:link-contacts --existing-only` sweep) pick it up
> automatically. This is the safety posture, not a gap.

**Consumers migrated so far:** the manual **"New lead"** create (`LeadsController@store`, with `StoreRequest`
previewing through the same classifier), WhatsApp's **`ContactLinker`**, the **member/lead CSV import**
(`BulkMemberImportAction`), the **legacy property-bookings import** (`ImportLegacyBookingsAction`), and
**Zoom poll respondents** (`PollRespondentLinker`, a backfill sweep). The old **Portal invite** path — once
the census's only true bypass — was **removed** (`Manage\Portal\UsersController` is read-only now; creating a
portal user is the Leads "New lead" modal). **Still bypassing the linker** — each resolves identity by hand
one level down at the gate, and its reserved `SOURCE_*` constant is declared but unused: the public
`/register` flow (`RegisterLeadAction`), Meta Lead Gen sync (`SyncFlgLeadToCrmAction`), event walk-in
check-in (`CheckInController`), Stripe webhooks (`WebhookProcessor`), and FB owner-listing
(`ProcessOwnerListingJob`). The full census is there.

### Identity resolution — ONE person = one User + one Lead (2026-07-14)
A person is deduped by **email OR phone**, and every path that could create a lead/user funnels through
the **central gate** `LeadRepository::firstOrCreateForIdentity(email, phone, name, groupId, role)`:
- **Email** matches exactly (MySQL `utf8mb4_unicode_ci`, so case-insensitive). **Phone** matches
  **tolerantly** — `user_profiles.phone` stores digits-only *keeping* the trunk-0 (`Src\Common\Support\PhoneNumber::digits`),
  so the same number legitimately exists as `0123456789` **and** `60123456789`; matching expands every
  candidate shape (`PhoneNumber::candidates`) and verifies each hit with `PhoneNumber::sameNumber`
  (canonical-E.164 equality) so a loose shape can never bind a *different* person.
- **Progressive backfill:** a person known by one key, later seen with the other, has the missing key
  filled in (`backfillContact`, which self-guards — only writes a blank field, and only when the value
  isn't owned by another account) instead of getting a second record.
- **Conflict** (email → A, phone → B, two different people) is never silently merged — the **email owner
  wins** and B's phone is not merged (`resolveUserByIdentity` returns `conflict`).
- **Staff/admins are never turned into a customer lead** (the gate returns null; the form entry points
  reject a staff match).
- **Every minted lead gets a main-portal role.** The gate's `$role` defaults to **`NON_MEMBER`**, so an
  account it creates is always a first-class customer. It used to default to `null` and only *some*
  callers passed a role, so every lead minted by **WhatsApp `ContactLinker` / Stripe / call matching /
  purchase-history import** was left **role-less** — and a role-less account is silently second class:
  it is not an `isMainUser()`, so `EnrollMemberAction::canEnroll()` refuses it (the UI then claimed
  *"This account is an admin and cannot be made a member"* — it is not), `SyncMembershipRoleAction`
  skips it, and the magic-link gate will not send it a sign-in link. Existing rows were repaired by
  [`2026_07_17_000001_backfill_main_role_for_role_less_leads`](/database/migrations/2026_07_17_000001_backfill_main_role_for_role_less_leads.php)
  (only accounts with **no** role at all; staff — including soft-deleted `admins` rows — untouched;
  an active subscription stamps `MEMBER`, else `NON_MEMBER`).
  - The guarantee covers **both** ways the gate creates a lead: a brand-new account (role passed to
    `UserRepository::create`) **and** a lead attached to an account that already existed without one —
    that second branch fills a **blank role set** the same way it backfills a blank email/phone. An
    account holding **any** role is never re-roled, so a paying Member is never knocked back to
    Non-Member by an inbound WhatsApp touch.
  - **Enrolment paths self-heal.** `EnrollMemberAction::ensureMainUser()` (shared by the CSV importer,
    the lead's **Convert to member** action and the membership **Add members** modal) stamps `NON_MEMBER`
    on a role-less *customer* before enrolling, and `EnrollMemberAction::isStaff()` is what those paths
    test to decide whether to say *"staff account"* — `canEnroll()` alone cannot tell "is staff" from
    "merely missing a role", which is what produced the misleading message.
- `firstOrCreateForPhone` / `firstOrCreateForEmail` are thin wrappers over the gate, so every consumer
  (Stripe, imports, WhatsApp `ContactLinker`, the call module's `LeadMatcher`, FB owner-listing) shares
  one tolerant rule. See the [WhatsApp contact↔lead](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md) linking.
- **`create()` uses `Lead::firstOrCreate`** (not updateOrCreate) so re-ingesting a returning person never
  clobbers their pipeline **status** (only fills a blank `flg_lead_id` / group).
- **⚠️ The public `/register` flow matches by EMAIL ONLY** (`RegisterLeadAction::resolveAccount`). It must
  never resolve/enrich an account via the unverified typed **phone**, because it then mails a magic
  sign-in link — phone-based merging there is an account-takeover vector (an attacker typing a victim's
  phone + their own email). Tolerant phone merge is only safe on admin / background-ingestion paths.

- **Admin quality verdicts — Agent / Fake, the human override (2026-08-17).** The AI's two quality guesses ([enrichment](/docs/modules_handbook/manage/leads/enrichment/readMe.md) `is_property_professional` + `fake_score`) can be **confirmed or overridden by a human**: `leads.agent_verdict` / `leads.fake_verdict` (0 no / 1 yes / NULL never judged — read with a null check, never falsy), each stamped with `_by` + `_at` (migration `2026_08_17_000001`). They live on **`leads`, not `lead_enrichments`**, for the same reason `properties_owned` does: the enrichment row is regenerated on re-run, and a never-enriched lead must still be taggable. A set verdict **always outranks the AI** — `Src\Lead\Support\LeadQuality` resolves the precedence once (`BuildsLeadInsight` and the Leads index both delegate to it), so the VSL / Registrations roster tags follow automatically (an admin-confirmed tag's tooltip reads "Confirmed by admin"; a human *genuine* silences the AI "Fake?" and a human *not an agent* silences "Agent-linked"). The UI is the shared **[`Components/LeadQualityTags.vue`](/resources/js/Components/LeadQualityTags.vue)** — tags + a "Verdict" dropdown (showing the AI's current guess as context) mounted in **both** identity headers: the Show page (Inertia write) and the `LeadDetailModal` (axios write → `?section=lead` re-pull + a host `router.reload()` so roster tags under the modal update too). Writes go to `POST manage/leads/{id}/verdict` (`VerdictRequest`: `kind` agent|fake + nullable `verdict`; `MANAGE_LEADS` + `LeadVisibility`) → `LeadRepository::setAgentVerdict()` / `setFakeVerdict()`; clearing (null) hands the surface back to the AI guess. Constants + labels: `Lead::VERDICT_NO/YES`, `AGENT_VERDICTS` / `FAKE_VERDICTS`. Pinned by [LeadQualityVerdictTest](/tests/Feature/Lead/LeadQualityVerdictTest.php) (endpoint, precedence, clear-restores-AI, tag-without-enrichment-row, payload).
- **The one-word lead quality — Legit / Agent / Fake (2026-09-08).** An admin thinks of a lead as
  *legit*, *an agent* or *fake* — one word, not two yes/no verdicts — so that word is now what the
  **Leads index `Quality` column** shows and sets, and what the verdict menu on the Show page /
  `LeadDetailModal` offers first (a "Lead quality" quick-set above the per-dimension buttons, which
  stay for what one word cannot say — *not an agent* alone). It is **not a third column**: each
  word is a fixed pair of the two verdicts above (`Lead::QUALITY_VERDICTS` — legit = no/no, agent =
  yes/no, fake = no/yes), written together in one transaction by `LeadRepository::setQuality()` via
  `POST manage/leads/{id}/quality` (`QualityRequest`: nullable `quality` in `Lead::QUALITIES`;
  `MANAGE_LEADS` + `LeadVisibility`; null clears BOTH verdicts) and read back by
  `LeadQuality::adminQuality()` (the exact inverse; fake outranks agent, a lone "no" is not a
  classification). A second column would have drifted from the filters, export and roster tags
  that already resolve `agent_verdict` / `fake_verdict` — this way they see the word for free.
  The column's chip is the **effective** reading from `LeadQuality::quality()` — the admin's word
  (shield mark), else the AI's two guesses folded into one hedged word (`Fake?`, `Agent-linked`;
  `Legit` only when BOTH dimensions were checked clean), else `Unjudged` — and the `<select>`
  under it is the admin's OWN word (`admin_quality`, blank until set). **Since 2026-09-17 the
  select only shows while there is something to set**: any row with a verdict renders a coloured
  chip + a **pencil**, which swaps in the select (focused, list opened via `showPicker()`) for that
  one row; blur / Esc closes it again, and a saving row keeps its dimmed select until the fresh
  props land. An admin's verdict carries the shield; **an AI verdict is prefixed `AI ·`**
  (`AI · Legit` / `AI · Fake?` / `AI · Agent-linked`, the hedged `quality_label`) — without the
  prefix an AI "Legit" was read as already confirmed. In the pencil's select the blank option keeps
  that row's own AI verdict as its text (never one AI option per quality: an admin cannot pick what
  the AI concludes, only overrule it). **Only an `Unjudged` row shows the select straight away**,
  reading `Not set`, with no chip. The colours are a traffic light — Legit green, **Agent amber**
  (was violet; `Lead::QUALITIES`, `LeadQuality::QUALITY_STATES` and the JS `QUALITY_STATES`
  together — the per-dimension `AGENT_STATES` roster chips stay violet), Fake red. (A viewer
  without `manage-leads` has no select, so they still see the Unjudged chip.) The per-row Agent / Fake
  chips that used to sit beside the name moved into this column; the drawer's Agent / Fake
  dimensions are unchanged and remain the way to slice on it. The export gains a `Lead quality`
  column (same label, `''` when unjudged). Pinned by the `quality` cases in
  [LeadQualityVerdictTest](/tests/Feature/Lead/LeadQualityVerdictTest.php) +
  [LeadQualityTest](/tests/Unit/Lead/LeadQualityTest.php).
- **Shared quality-state resolver — Agent / Fake filters, counts & export (Phase 2–5, 2026-08-18).**
  `Src\Lead\Support\LeadQuality` turns the two verdict pairs above into ONE effective state string
  per dimension (`agent`/`agent_linked`/`not_agent`/`unjudged`, `fake`/`genuine`/`unjudged`) plus
  WHO decided (`agent_source`/`fake_source`: `admin`|`ai`|null) — the single place admin-vs-AI
  precedence is resolved, as both a PHP method (`agentState()`/`fakeState()`, used by
  `BuildsLeadInsight` for the roster payload and the index row's `transform()`) and the SAME
  mapping as a SQL `CASE` fragment (`agentStateSql()`/`fakeStateSql()`, a correlated subquery on
  `lead_enrichments` keyed by its own unique `lead_id` — never a join, GUIDELINES §14). The Leads
  index filters on it (`agent[]`/`fake[]` in `LeadQueryRequest`, a whitelisted `whereRaw`) with
  faceted `agentCounts`/`fakeCounts` (`LeadsController@index`, grouped by the **SELECT ALIAS** —
  `groupByRaw(<expression>)` is a latent `ONLY_FULL_GROUP_BY` 500 the moment that MySQL mode
  tightens, caught in QA). The export (`LeadsExport`) adds **`Agent verdict`** / **`Fake verdict`**
  TEXT columns carrying the resolved LABEL (never the int), `''` when unjudged. **The "Fake?"
  hedge**: an AI-SCORED fake reads `"Fake?"` — never presented with a human confirmation's
  certainty, `"Fake"` — resolved by `LeadQuality::fakeLabel($state, $source)` (`agentLabel()`
  alongside it, though the agent dimension has no hedge to resolve), so the export,
  `QualityTagChips.vue` and `LeadQualityTags.vue` can never disagree on the wording.
- **The shared tag component.** `resources/js/Components/Leads/QualityTagMeta.js` is the single
  label/colour/icon source on the frontend (mirrors `LeadQuality::AGENT_STATES`/`FAKE_STATES` 1:1)
  plus `qualityStates(insight)` — the ONE place the **`insight === null` contract** is applied:
  `agent_state`/`fake_state` default to `'unjudged'`, both sources to `null`, since
  `registrantInsight()` returns `null` (and the index row omits the keys) for ~88% of leads
  (never enriched, never judged) — every consumer spreads `qualityStates(insight)` rather than
  reading `insight.agent_state` unguarded. `Components/Leads/QualityTagChips.vue` is the
  display-only row-cell rendering (Fake → Agent order, a `+N` popover on hover/tap/focus, an
  admin-confirmed "clean" state shown while the AI equivalent is silence), mounted on the Leads
  index, the VSL funnel roster and the session Registrations roster — replacing what used to be
  three separately hand-rolled pill blocks. `matchesQualityOption(insight, 'all'|'agent'|'fake'|
  'clean')` is the roster TRIAGE-bucket definition (folds `agent_linked` into Agent; `clean`
  includes an admin-cleared row exactly like a never-judged one) shared by
  `useVslRosterFilters.js`'s QUALITY pill group and `RegistrationsTab.vue`'s drawer dimension —
  **the two rosters keep DIFFERENT filter idioms on purpose** (VslLeadsTab.vue is already an
  all-pills roster; RegistrationsTab.vue is already a `FilterDrawer` roster, and its `quality`
  dimension carries a `counts` map computed the same way `source`/`campaign` are) but read the
  SAME predicate, so the two can never disagree on what counts as Agent / Fake / Clean.
- **`prof_source` (2026-08-18) — data-only so far.** `lead_enrichments.prof_source`
  (`own_bio`/`search`/`ai_report`) records WHICH tier produced the stored
  `is_property_professional`, enforcing a merge trust order **own_bio > ai_report > search** (see
  the [enrichment handbook](/docs/modules_handbook/manage/leads/enrichment/readMe.md)'s "Quality
  verdicts" section for the full rule). ⚠️ **Nothing walks a weak verdict back yet** — a stored `search`-tier
  RELATED is not cleared by a later `ai_report` NO, because the merge compares verdict RANK first
  and only breaks a same-rank TIE by source tier. A genuine walk-back mechanism is a follow-up
  task, not built here.
- **Admins are full leads, badged "Admin" (`leads.is_staff`).** Every admin carries a Lead facet (their own portal identity — so staff can use + write the member-portal features), flagged `is_staff`. This is a **label, not a filter**: an admin-lead is **visible everywhere a lead is** (list, counts, pipeline), and the list transform emits `is_staff` so the name cell shows an **Admin** badge. Promoting a customer to admin therefore **keeps all their records** visible. See the [identity foundation](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) → *Admin + Lead are hats*.
- `Lead` belongs to a `User` and `hasMany` **`leadFunnels`** (`Src\Lead\LeadFunnel`, the registrations) + `belongsToMany` **`funnels`** through `lead_funnels`. `STATUSES` is on `Lead`; **`SOURCES`** (**Funnel(9) / Meta Lead Gen(6) / Owner Listing(5) / Property Match(8) / Other(4)** — the 2026-07 consolidation retired FMX/MKT/Organic/Messenger, remapped by migration `2026_07_24_000001`; "which agency / paid-or-organic" lives in the row's own `utm_*` columns and "which ad account" resolves via `campaign_id`/`ad_id` → `meta_ad_refs`) is on **`LeadFunnel`** (source is per registration). Every landing registration stamps **Funnel**; ingestion paths that used to leave a blank source (Stripe, walk-in, WhatsApp, phone-call) now stamp an un-funneled **Other** row via `LeadFunnelRepository::attachDefaultSource` (existing attribution is never diluted).
- **A registration also banks the two Meta browser ids, `fbp` + `fbc` (2026-07-29).** Every landing registration writes them alongside the `fbclid` (`LandingController` reads `_fbp` from the cookie and derives `_fbc` via `clickId()`, `RegisterLeadAction` persists both onto the `lead_funnels` row). They are **read back long after the visit** by two consumers, which is why they are stored rather than just forwarded to Meta at the moment of the conversion:
  - `App\Actions\Marketing\ReportPurchaseToMetaAction` replays `$touch->fbp` / `$touch->fbc` on the server-side **Purchase** event — money is confirmed by a gateway webhook with no browser and no cookies, so these are the strongest click→conversion link Meta still has.
  - `AdPerformanceService` uses `fbc` (with `fbclid` / `campaign_id` / `ad_id`) as one of the four "came from an ad" markers in its **first-touch** correlated subquery — the same broad test as `ReportPurchaseToMetaAction::firstAdTouch()`, ordered `registered_at, id` so the credited ad never flip-flops. A lead with **no** ad-marked registration is silently skipped, by design.
- Index list filtering goes through `LeadQueryRequest`: **search** queries the related user + profile; **funnel** filter = `whereHas('funnels', uuid)`; **source** filter = `whereHas('leadFunnels', marketing_source)`; **agent** / **fake** filter = `whereRaw(LeadQuality::agentStateSql()/fakeStateSql() . ' in (…)')` (see the shared quality-state resolver bullet above). The funnel + source **counts** are `count(distinct lead_id)` over `lead_funnels`; the agent + fake counts are grouped by the state SQL's own select alias.
- **CLV — Customer Lifetime Value, beside Membership (2026-09-19).** Founder: *"CLV = Membership + Converted
  Commission + MLTA Referral Fee + Rental Commission + Renovation Profit + Management Fee (accumulated), all
  these numbers taken from the Sales hub."* Membership says what somebody paid for ONE product; CLV is what the
  whole company has EARNED from them — a different ranking: that day the smallest commission among our buyers
  (RM 9,466) was nearly three times the largest membership total on the list (RM 3,265). 546 leads carry a CLV,
  RM 5.67M in all, RM 4.89M of it commission.
    - **One class owns it: [`Src\Lead\Support\LeadClv`](/src/Lead/Support/LeadClv.php).** `PARTS` is the formula
      (name, what it means, what counts, the records it reads, the Sales hub page that owns the figure,
      `recorded`); `forLeads()` is four grouped queries for the page; `totalSql()` is the SAME subqueries as a
      correlated expression, so the column **sorts by exactly the figure the cell prints** (0.24s over 12,692).
    - **Earned, not promised.** Commission counts bookings whose deal reached **Converted** only; Rental counts
      **Closed** records; Renovation counts **won** jobs (`RenovationJob::WON_STATUSES`). A booking in progress
      or a quote nobody accepted is a hope, not a ledger entry.
    - **What WE keep.** Renovation profit is `commission_amount` — the figure the Jobs page already calls "what
      this business keeps" — never `contract_value`, which passes through to the contractor.
    - ⚠️ **The commission is DERIVED, and derived twice.** `Booking::commissionAt()` works it out in PHP for the
      Bookings page (legacy absolute `commission`, else rate × basis price + adjustment); `LeadClv::commissionSql()`
      is its SQL twin, because a sort cannot call PHP. Checked against all 246 converted bookings on the live
      database (RM 4,886,591.52 both ways, 0 mismatches) and pinned by
      [LeadClvTest](/tests/Feature/Manage/Leads/LeadClvTest.php), which walks every shape of booking through both.
      **Change one, change the other.**
    - ⚠️ **MLTA and Management are in the formula and NOT in the total** (`recorded: false`). Both have a Sales
      hub tab but no table that stores a fee — MLTA has only its pipeline, Management nothing. The hover prints
      *"not recorded yet"*, never RM 0: a zero would claim we earned nothing, when the truth is nobody has written
      it down. The day a Sales stage exists: flip the flag, add the part to `partQuery()` / `leadColumn()` /
      `amountSql()` — the column, the hover and the guide page all read that one list.
    - ⚠️ **Rental links by `lead_id`, and all 3 rental records today have none** — so Rental commission is RM 0
      for everyone until the Rental desk links its records to leads. Not a bug in the sum.
    - The header is the word **CLV** with a `title` spelling it out and a signpost to **`/manage/leads/clv`**
      ([`ClvGuideController`](/app/Http/Controllers/Manage/Leads/ClvGuideController.php) →
      [`ClvGuide.vue`](/resources/js/Pages/Manage/Leads/ClvGuide.vue)) — the formula drawn as an equation (each
      part wearing its Sales hub tab's icon, the two unrecorded ones dashed), the three rules, and a card per
      part. New tab, like the Status guide. Literal route, declared before `GET {id}`.
    - **The Membership header is a WORD again** (it was a banknote icon): with CLV beside it, two money columns
      under two money icons could not be told apart.
- **⚠️ THE REPLY MARK HAS FOUR AGES, NOT A 30-DAY LINE (2026-09-19, the third cut that day — it supersedes
  "WAITING / MISSED" wherever the two bullets below say it).** Founder: *"dark red 24 hours (white ! red
  background), then 24–48 (red), yellow (48–72 hrs), grey (>72 hours)."* `ReplyOwed::TIERS` — `h24` · `h48` ·
  `h72` · `older`. **Hottest when FRESH**, on purpose: a reply within a day is the target, so this morning's message
  is dark red and last month's fades. 2 · 3 · 3 · 330 leads that day, and with "nothing owed" (12,354) they
  partition the list exactly.
    - **One clock.** The tier is decided on the SERVER (`wa_owed_tier`, `reply_tier`) and the `needs_reply` filter
      (`yes` · `h24` · `h48` · `h72` · `older` · `no`) draws its lines with the same PHP `now()`, BOUND into the
      query — never `NOW()` in SQL (the database server's clock and timezone are not the app's). Filtering on a
      colour returns exactly the rows that wear it; a mark must not change colour a minute before its filter does.
    - **The clock starts at the FIRST unanswered message** (`owed_since`, `ReplyOwed::owedSinceSql()`), not the
      latest: a customer who writes again every few hours has not reset our deadline, they have underlined it.
      Until `whatsapp:refresh-reply-states` has read a new message the list can only see the latest one, so it
      fails toward the HOTTER colour — never toward silence. With two owed threads the FRESHEST decides
      (`max(owed_since)`): it is the one that can still be saved.
    - **Red tabs on the lead = the first three tiers** (`TIERS[..]['alert']`). Past 72 hours the thread strip says
      "Never answered · 3mo ago" in grey; nobody "replies" to last month, they re-open it.
    - Colours live in [`utils/replyTiers.js`](/resources/js/utils/replyTiers.js), read by the list's "!" (after
      the phone number), the Message cell's chip and `ThreadPerformanceBar`. ⚠️ **Every tier is a FILLED mark** —
      the grey one is solid mid-grey with a white "!". The first version's hollow grey-on-white was rejected as
      "difficult to differentiate" beside a phone number. ⚠️ Waits print in HOURS up to 72 (`waitedFor()`):
      `relativeAge()` rounds to days after 24h, so a 60-hour wait read "3d" inside a chip coloured "48 – 72 hours".
- **⚠️ "WAITING ON OUR REPLY" IS NO LONGER THE INBOX'S RULE — read this before the bullet below (2026-09-19, same
  day).** The founder opened a lead that was red because the customer's last message was 👌: *"need a smarter
  rule."* The data agreed — of 443 lead threads `InboxState` called "needs reply", **51 ended on an emoji,
  dozens on "ok" / "thanks" / "好的，谢谢", and 269 were more than ninety days old**. 413 red leads became **46**.
    - **`InboxState` is untouched** and still drives the inbox, the mobile app and the AE dashboard: an AGENT
      should see every thread where the customer spoke last. The Leads list is a MANAGER's reading — who did we
      fail to answer — and layers two questions on top, in
      [`ReplyOwed`](/src/Whatsapp/Support/ReplyOwed.php):
        1. **Did anything they said expect an answer?** [`ReplyExpectation`](/src/Whatsapp/Support/ReplyExpectation.php)
           reads the OPEN TURN (every customer message since our last confirmed reply), not just the last
           message: "Can I join tonight?" + "👍" is still a question. No: reactions, stickers, system notices,
           emoji/punctuation-only text, text made ENTIRELY of closing words (ok, noted, thanks, tq, 好的, 谢谢,
           收到, baik, terima kasih… in any combination). Yes: a question mark anywhere, any media / voice note
           / document / location / missed call / tapped button, and everything else. ⚠️ **"yes", "sure", "ya",
           "can" are NOT closings** — they answer a question of OURS ("reply YES for the link") and put the next
           move on us. It **fails toward YES**: a false red costs a glance, a false all-clear costs a customer.
           Deterministic on purpose — the screen prints the reason ("an emoji, not a question"), which an AI
           verdict could not do identically twice. Every case in
           [ReplyExpectationTest](/tests/Unit/Whatsapp/ReplyExpectationTest.php) is a real last-message shape.
        2. **Is it still a reply, or history?** `ReplyOwed::LIVE_DAYS` = 30 (the same line `LeadStages` draws for
           a live conversation). Inside it: **WAITING** — red. Past it: **MISSED** — a hollow grey "!" in the
           list, amber "Never answered · 3mo ago" on the thread, NOT red tabs. A three-month-old question is a
           failure worth counting but a re-activation, not a reply; painting it red buried this week's.
    - **Two ways to ask, one reading.** The lead page reads the messages LIVE (`ReplyOwed::read()`), so it is
      exact when it loads. The list cannot classify text in SQL without a PHP/SQL twin drifting, so
      `whatsapp:refresh-reply-states` (every 5 min; only conversations with a message newer than their last
      reading; first full run 1,690 conversations in 34s) writes `whatsapp_reply_states`, and
      `ReplyOwed::whereOwed()` filters on it. ⚠️ **The cache can only REMOVE a thread from the red list**: no
      reading, or a reading older than `last_inbound_at`, counts as OWED — so a late run leaves a mark on an
      "ok 👍" for five minutes and can never hide a waiting customer; and OUR reply clears a mark instantly,
      because that half is `InboxState`'s live SQL. A side table, not columns on `whatsapp_conversations`: that
      table is on every inbound webhook's hot path and the ingestion code was deliberately not touched.
    - Filter `needs_reply` = `yes` (waiting) · `missed` · `no` (nothing owed). The thread strip says WHY a
      thread where they spoke last is not red (`reply_closing`).
- **WAITING ON OUR REPLY — a red mark in the list, red tabs on the lead, indicators on each thread (2026-09-19).**
  Founder: *"if there is any message pending us to reply, mark the tab red; at lead table mark ! red too … this
  helps us identify which lead we haven't responded."*
    - **ONE definition, and it is the inbox's:** `Src\Whatsapp\Support\InboxState` — the customer's latest inbound
      has no CONFIRMED outbound (sent / delivered / read) at or after it. A queued or failed send leaves them
      waiting. Nothing here restates that rule; it is reached three ways through
      [`LeadConversationPresenter`](/src/Whatsapp/Support/LeadConversationPresenter.php): `pendingCountForLead()`
      (the lead page's eager `whatsappPendingCount`), `awaitingReplyPerLead()` (a query correlated on
      `leads.user_id` — the list SELECTS `wa_pending_count` / `wa_pending_since` out of it and the
      `needs_reply` filter runs `EXISTS` on it, so **filtering on "waiting" returns exactly the rows that wear the
      mark**), and `needs_reply` per thread in `forLead()`.
    - ⚠️ `awaitingReplyPerLead()` restates `baseQuery()`'s three exclusions as joins — 1:1 only, no **sandbox**
      line, no **CEO Dashboard** number (somebody's PERSONAL WhatsApp; a contact deduped by phone across both
      numbers would otherwise put the owner's private thread on a lead's row). Change one, change the other;
      [LeadWorkQueuesTest](/tests/Feature/Manage/Leads/LeadWorkQueuesTest.php) pins both exclusions.
    - **In the list the mark sits beside the NAME** — the pinned column — because Message is eight screens to the
      right and a warning nobody scrolls to is not a warning. The Message cell repeats it with the age
      (`reply · 3d`). Both link to the lead on `?tab=channel&ctab=whatsapp`. 413 of 12,692 leads that day.
    - **On the lead page BOTH tabs go red** — WhatsApp and its parent Channel (`useLeadTabs` → `whatsappAlert`): a
      red sub-tab under a grey parent is a warning nobody sees. `ShowTabs` tabs take `alert` (+ `alertTitle`): a
      STATE, not a second number — the badge keeps counting records and turns red with the label.
    - **Each thread wears an indicator strip** ([`ThreadPerformanceBar`](/resources/js/Components/Conversation/ThreadPerformanceBar.vue),
      fed by [`ThreadPerformance`](/src/Whatsapp/Support/ThreadPerformance.php)): waiting-since (red) / replied,
      **usual reply time**, **answered %**, in / out, when we last replied. A thread is read as CUSTOMER TURNS — a
      burst of messages is one turn, timed from its FIRST message, closed by our next confirmed outbound.
      The reply time is the **median** (one long weekend is not our reply time), over the WHOLE thread rather
      than the 30 messages the tab loads, in clock time. Reactions and system notices are not turns. `null`, never
      0, when we have never replied. "Red" on the strip is `needs_reply` (the inbox's verdict), never the strip's
      own reading of the figures.
- **ACTION — the last column: outstanding vs closed action items, listed on hover (2026-09-19).** The
  Discussion tab's checklist (`lead_action_items`) read without opening the lead: amber = OUTSTANDING, green =
  closed; the hover lists up to 8 (open first, then by priority) with status, priority, type, due date and
  assignees, and says how many it left out. One query per page (`LeadsController::actionItemSummary()`); sorts on
  the outstanding count; `action_open` filters. Names and colours are `LeadActionItem::STATUSES` / `PRIORITIES`.
  The popover anchors to the cell's RIGHT edge — it is the last column, a centred card would hang off-screen.
- **The row's Edit button left the list (2026-09-19)**, a few hours after Delete did — founder: *"whatever wanna
  edit, click the lead profile and edit inside the lead detail page."* The row already opens the lead and the
  Lead page mounts the same `LeadFormModal`. With no `#actions` slot the table has no actions rail at all;
  `LeadFormModal` stays on the list for **Add lead** only.
- **FOLLOW UP — the Client Success column, renamed, as three circles, FIRST in the table (2026-09-19, the third
  cut that day).** Founder: *"rename Client Success to Follow Up, then change to 3 circles with logo only, then
  only KX, WK after assign. Follow Up column is between # and Lead."* Everything in the bullet below about
  storage, the one-role-per-request write and what it is NOT still holds — **only the label and the cell
  changed**; the table, model, route and cell file keep their `success` names (renaming a label is not worth a
  migration and a broken deployed bundle).
    - **An empty circle is dashed and wears its role's icon** (chart = Analyst, phone = Caller, handshake =
      Closer); **a filled one shows ONLY the holder's initials**, in THEIR colour — the same `initials()` rule and
      the same [`staffColour`](/resources/js/utils/staffColour.js) the Rep column uses, so "KX" is one person
      wherever the row prints them. Which role a circle is comes from its position (fixed) and the tooltip /
      `aria-label` ("Closer: Boon — …"), since the circle itself has no words.
    - `FilterSelect size="dot"` — a 24px round trigger that draws only its `#prefix` and takes its look from
      `triggerClass`. ⚠️ The base class no longer carries `w-full` (it moved into the non-dot sizes): with both
      `w-full` and `w-6` on the button the circles rendered as 12px ovals, which only a screenshot showed.
    - **A rule separates Follow Up from Lead** — both are pinned and share a background, so without it the
      circles read as part of the Lead cell. ⚠️ It is `PINNED_RULE`, a full-height `::after`, NOT `border-r`: a
      border on a pinned cell of this `border-collapse` table does not paint (a screenshot showed no line at
      all) — the same reason DataTable draws its own sticky divider as a pseudo-element, and it only draws that
      one after the LAST pinned column.
    - **Pinned with the name: `:sticky-columns="2"`** plus `stickyWidth` 88 (Follow Up) and 192 (Lead). The
      pinned offsets are COMPUTED from those widths, so they must match what the cells need; `sticky-first`
      alone would have pinned Follow Up and let the name scroll away.
- **ENGAGED is one chip, not two lines (2026-09-19).** The cell stacked an age ("3d ago") over a badge (icon +
  "Message") — ~95 × 40px. Now: the channel's icon in the channel's colour, then the age without its "ago"
  (`shortAge()` only trims `relativeAge()`'s output, so the arithmetic still lives in one place) — ~40 × 16px.
  The word "Message" was the widest thing in the cell while the icon beside it said the same; channel name,
  full age and date are in the tooltip. Header in the same 9px sentence case as Membership, column at `px-1`.
  ⚠️ The first cut set the chip at 10px and the header at 9px and the founder called it within the hour (*"the
  engaged font is so small!"*): **compact means fewer words, not smaller ones.** The chip is the table's own 12px
  and the header 11px; all the saving came from dropping the second line and the two words.
- **Membership and CLV sit under a `VALUE (RM)` band, and their cells drop the "RM" (2026-09-19).** The band is
  where the unit lives — printing "RM " in every cell of two columns was ~50px repeating a header (the hover
  cards still spell it out). Both run at `px-1`. The word **Membership** stays, as asked, but in sentence case
  at 9px via `#header-memberships`: in the table's uppercase tracked header face it was ~80px, wider than any
  figure under it — the HEADER was what made the column wide. `MembershipCell` / `ClvCell` take `compact`.
- **CLIENT SUCCESS — Analyst / Caller / Closer per lead, assignable in the list (2026-09-19).** Founder: *"each
  lead got Analyst, Caller, Closer, so each Client Success role I can assign a person … each lead would have 3
  rows."* One line per role, each a dropdown that saves on its own.
    - **Storage:** `lead_success_roles` (lead_id, role, admin_id → `users.id`, blame; **unique (lead_id, role)**),
      [`Src\Lead\LeadSuccessRole`](/src/Lead/LeadSuccessRole.php) (`ROLE_*` + `ROLES` — key, name, colour,
      meaning), `Lead::successRoles()`, cascaded in the lead's `deleting` hook. Rows, not three columns on
      `leads`: the role list can grow and a row records who assigned it.
    - **Write:** `PUT manage/leads/{id}/client-success` (`role` + nullable `assignee` uuid) →
      [`SuccessRolesController`](/app/Http/Controllers/Manage/Leads/SuccessRolesController.php) →
      `LeadSuccessRoleRepository::assign()` (updateOrCreate; null empties the role). `MANAGE_LEADS` +
      `LeadVisibility`, and the assignee must come from the actor's own `assignableManagerQuery()` pool — a group
      member cannot reach outside their group by posting a uuid the dropdown never showed.
      **ONE role per request**, on purpose: a whole-team PUT from a list loaded five minutes ago would overwrite
      the Closer a colleague set since.
    - ⚠️ **It is not the Account manager, not the deal's closer, and it moves no money.**
      `leads.assigned_admin_id` (below) still decides **who can SEE the lead** and what lead distribution counts;
      `engagement_assignments` is ONE DEAL's team and **is the commission split** — a lead with three projects may
      have three different closers there. Nothing here touches visibility or commission: paying somebody because
      their name sits on the lead would be a bug. Assigning a Closer here does NOT make the lead visible to them.
    - **Two rows, not three (same day, founder: *"make it more compact, like analyst and caller share one row,
      make font smaller"*).** Analyst + Caller share the first row; the Closer spans the second — theirs is the
      name a manager reads the column for, so it gets the width. `FilterSelect size="xs"`: 20px tall, 10px type,
      NO chevron (in an 80px control it was the difference between "Analyst" and "Anal…" — a screenshot caught
      that), and no label beside it: an EMPTY control names its ROLE via `emptyText` ("Caller") while its menu
      still offers "Unassigned", a filled one wears the role's icon in the `#prefix` slot (chart / phone /
      handshake), and the tooltip spells out both. 42px per lead instead of 76.
    - UI: [`Partials/ClientSuccessCell.vue`](/resources/js/Pages/Manage/Leads/Partials/ClientSuccessCell.vue) on
      `FilterSelect` — in a table row a value is the NORMAL state, so a set one is plain and the EMPTY
      one is the dashed hole to fill (the reverse of the filter panel, where set is the exception). A holder who
      has left the assignable pool still displays. Read-only text without `manage-leads`. Sits beside **Rep**:
      Rep is who has actually spent the hours, this is who is supposed to.
- **The list was re-cut again for width (2026-09-19).** `#` runs at 3.5rem, not 5rem (`DataTable compact-index`
  — width, sticky offset and pixel count move together, see `stickyIndexWidth`); the four PROPERTY CLOSING
  columns run at `px-1`; ENGAGEMENTS is ordered by how much of a PERSON was on the other end — **Zoom Meeting,
  Zoom Webinar, Phone Call**, then Message, Portal, Showroom F2F, AI Call — so the three that fit before anyone
  scrolls are the three worth seeing. **Row DELETE left the list**: it bought back a button's width on every
  row, and an irreversible hard delete is better made from the Lead page, where what is about to be lost is on
  screen. Leads → Show still has it (and its guards, below, are unchanged).
- **Membership column = the lifetime total paid, details on hover (2026-09-17).** The cell prints
  only `membership_total_paid` (`withSum` over **every** subscription, any status — also the sort
  key). Hovering it opens [`Partials/MembershipCell.vue`](/resources/js/Pages/Manage/Leads/Partials/MembershipCell.vue)
  — the same teleported popover as the Property band's `PropertyCell` — listing the joining date
  and one line per subscription from `membership_items` (`name`, `price_paid`, `paid_at`,
  `status_label` / `status_color` off `MemberSubscription::STATUSES`). The index therefore
  eager-loads ALL subscriptions, not only active ones, so the lines add up to the figure beside
  them. The old `memberships` key (active tier names) is still sent for the previously deployed
  bundle only — nothing renders it; delete it a deploy later.
- **PROPERTIES OWNED reads "On record" and "They said" (2026-09-18).** The two columns were called
  Archive and Told us — one named our own database, the other named an occasion, and neither said the
  thing the band exists to show: that these are two different CLAIMS, an independent record beside the
  person's own answer, kept apart precisely because they disagree. The pair is lending's own
  vocabulary (stated income vs. verified income) minus the overclaim — a phone match against a
  public-records archive is **on record**, not verified, and its confidence band rides in the cell.
- **The PROPERTY CLOSING hovers name the closer and the property (2026-09-19).** Hovering a Book /
  Convert / Drop number opens one small card per deal
  ([`Partials/PropertyCell.vue`](/resources/js/Pages/Manage/Leads/Partials/PropertyCell.vue)): the
  project, WHO closed it, and WHAT was closed — "Flexus · Zen Chua · Closer · Unit 19-03 · RM 285k ·
  booked 25 Sep 2025". Total's hover adds the closer's name beside each project.
    - ⚠️ **The closer is read from `engagement_assignments` (roles `closer`, `webinar_closer`), NOT
      from `bookings.closer_admin_id`** — that column is empty on all 480 bookings, so the obvious
      source would have printed nobody. Only 132 of the 476 closing engagements carry a closer
      assignment (the rest are legacy imports), and those cards say **"No closer recorded"** rather
      than borrowing the lead's account manager, who may never have touched the deal.
    - Property info is the engagement's latest `booking`: block / floor / unit (477 of 480 filled),
      net price (480; shortened on the card, exact figure + SPA price on its title) and booking date
      (473). `built_up` is filled on 4 bookings and is left out. A missing value is not printed — a
      dash would read as a value.
    - The booking date's month is formatted by hand: `toLocaleDateString` gives "Sep" in one ICU
      build and "Sept" in another (it failed the test in Node while reading fine in a browser).
    - Loaded on the existing `propertyRecords` eager load (`booking`, closer-role `assignments` with
      `admin.profile`) — batched for the page, no per-row query.
- **Colour carries the PROPERTY CLOSING band (2026-09-18):** Convert is green across the whole
  column, Drop red, Total grey — the band reads as traffic lights from across the room and the
  numbers are consulted second. Total is deliberately NOT a fourth colour: it is the sum of the
  three beside it, not an outcome of its own. Cell tints stay translucent so zebra striping and row
  hover still show through.
- **Headers are icons, from one `iconHeaders` list** — Σ for the total, a crossed circle for a
  dropped deal, a banknote for membership (the cell is money — what they have PAID, not which tier),
  a webinar screen for Declare (the declaration is literally made in a webinar poll, so it wears the
  webinar's own icon). Rep · Engaged stayed as WORDS, one each: an icon has to be decodable cold, and
  those two had no icon that was. **Report earned one** when its cell stopped being a link and became
  two figures (below) — a column of numbers needs its heading to take no width, and the cell's own
  icons say what each number is.
- **The list was re-cut for width and for scanning (2026-09-18).** Seven changes, each one either a
  column reclaimed or a click removed:
    - **Quality rides beside the NAME**, not in a column of its own — it is a fact about the person,
      and a whole column for one small chip is width this table does not have. The inline editor
      (pencil → select) moved with it, `@click.stop` so setting a verdict does not also open the lead.
    - **PROPERTY collapsed to ONE column, then came back as four.** The experiment printed the total
      with a `2·1·0` split under it
      ([`Partials/PropertyRecordCell.vue`](/resources/js/Pages/Manage/Leads/Partials/PropertyRecordCell.vue),
      still the cell in use) and named the actual PROJECTS on hover — open pipelines first, because
      those are the ones somebody might have to do something about today. The founder reversed it the
      same day: **PROPERTY CLOSING** is Book · Convert · Drop · Total again, one column per outcome,
      because the split under a total is read only by whoever already suspects it matters, while four
      columns are compared across rows at a glance. `PropertyRecordCell` renders all four — the three
      outcomes in `totalOnly` mode (their own number, their own projects on hover), TOTAL keeping the
      pipelines hover, which is the one thing the outcome columns cannot show. "Drop", not "Lost":
      the team's word; the status CONSTANT behind it is unchanged, so the hover still reads the
      model's own label. Every one of the four sorts (`property_booked` / `_converted` / `_lost` /
      `_record` — all four server sort keys were kept through the experiment, which is why the
      reversal cost nothing).
    - **Financial Report is the LINK to THIS lead's report page** — `/financial-report/{token}`, the
      page the customer signed consent for, **not** the provider's raw file (`whopay_report_url`,
      which is still sent and still fills the old tick). The token lives on `lead_consents` and is
      minted the first time an admin opens the report, so the cell resolves in three steps: token →
      the public page; signed but never opened → `manage.fpa.report`, which mints one and redirects;
      **no signed consent → no link at all**, because the page only exists once the customer has
      authorised it. Today that is 12 leads with a token, 150 signed-and-unminted, out of 162 signed
      consents. A row with provider data but no consent keeps the tick — it has the data, there is
      just nothing to open.
    - **The READINESS columns run at the width of their icon** (2026-09-18): `padClass` on a column
      REPLACES the table's cell padding (two padding utilities in one class attribute are resolved by
      stylesheet order, not by the order they are written, so `px-2` would have won anyway), and the
      tiles are 18px. ~150px of table back, with nothing smaller than a glance can read.
    - **The ENGAGEMENTS headers are LOGOS.** Seven words across seven narrow count columns is most of
      the table's width spent on labels that never change; each icon is the one that channel already
      wears on the Last Engage badge, so the vocabulary is learned once (Zoom Webinar is a
      `Presentation` — somebody presenting to a room, which is what separates it from the meeting's
      camera; the flame it wore first meant nothing). The name stays for a screen
      reader and on hover — unspelled, never hidden.
    - **"Last Engagement" → "Last Engage"**, for the same reason.
    - **Merge left the row actions** for the lead's own page. It is a decision about one person made
      after reading them, not something taken from a list; `Show.vue` already carries the whole flow.
    - **The identity cell kept its original shape** — WhatsApp line, email under it. A one-line
      experiment (phone · email) was reverted: they are two different ways to reach somebody, and a
      column of phone numbers is read by running down it.
- **The two SPOKEN columns name who was on the other end (2026-09-18).** Zoom Meeting and Phone Call
  print the team member's initials under their number —
  [`Partials/StaffInitials.vue`](/resources/js/Pages/Manage/Leads/Partials/StaffInitials.vue), two at
  most and then `+n`, with the full name and count on hover. "Has anyone spoken to this lead?" is a
  question a duration cannot answer and "Kexin, twice" can. `LeadsController::staffPerLead()` runs
  one grouped query per channel over `admin_id` (100% populated on both tables), joined through
  admins → users → user_profiles — the same resolution `Admin::displayName()` performs (§4.8) — at
  ~51ms for 69 leads. The initials rule is server-side so both columns share one: two words give
  their first letters (Wai Kit → WK), one word gives its first two (Kexin → **KE**; no rule derives
  an X from that spelling, so a per-admin short code would be the way to control it exactly).
  ⚠️ Each channel repeats its COLUMN's rule — ignored calls excluded, Zoom meetings happened-only —
  or a cell would credit somebody with a call the number beside it does not count.
  This is also why [`ZoomMeeting::applyHappened()`](/src/Zoom/ZoomMeeting.php) now QUALIFIES its
  columns: it filtered on a bare `status`, which is ambiguous (error 1052) the moment a caller joins
  a table that has one too — `admins` does. Qualifying costs nothing without a join.
  **Each person carries their own TIME**, not a share of the count: with two people on one lead the
  split is the thing worth knowing ("8m (2)" hides that one of them spent seven of those minutes).
  The hover card lists everybody, longest first — the server sorts by seconds, not by sessions,
  because four two-minute calls are not a relationship and one half-hour meeting might be. Zoom
  contributes `duration` × 60: MINUTES, and what the meeting was BOOKED for — the same approximation
  the Zoom column sums, since no actual duration is stored anywhere.
- **TOP REP (2026-09-18)** — the colleague whose hours are actually in this relationship, beside Last
  Engage so one column says WHO and the next says WHEN.
  [`TopStaffCell.vue`](/resources/js/Pages/Manage/Leads/Partials/TopStaffCell.vue) prints their
  initials and their total spoken time; the hover breaks it down per channel, so the figure never has
  to be trusted. It is **not the assigned admin** (a decision somebody recorded, which may never have
  become a conversation) and **not the most recent one** (an accident of scheduling): it is time
  spoken, summed across phone + Zoom meetings + showroom — every channel with a human on the other
  end, which is the one measure intent cannot inflate. Ties break on sessions, then on name, so the
  answer is stable between page loads. `topStaffPerLead()` merges the three channel maps in PHP:
  **30ms for 104 leads**, no extra query.
  **It sorts, since 2026-09-18** (founder: *"portfolio columns, rep column need to be sort too"*), and
  by the rep's NAME — sorting by Rep means "show me one person's leads together", not "rank my team".
  The earlier note here said it could not sort without materialising the argmax; that turned out to be
  a third option: express the SAME rule once more in SQL.
  [`topRepPerLead()`](/app/Http/Controllers/Manage/Leads/LeadsController.php) unions the three
  channels' per-(lead, admin) aggregates — each repeating ITS column's exclusions, ignored calls out
  and Zoom happened-only — sums them per colleague and keeps `row_number() = 1` per lead. **269 leads
  with a rep in 0.14s; the whole sorted, paginated list in ~0.6s over 12,689 leads.**
  ⚠️ It is a `leftJoinSub`, which §14 forbids — and the reason §14 forbids joins is row multiplication
  inflating the paginator count. This subquery is one row per lead by construction, the count is
  unchanged (12,689 either way, pinned by the test), and a correlated version is impossible: MySQL
  cannot reference the outer `leads.id` inside a derived table. It is built only on this sort.
  Leads nobody has spoken to sort LAST in both directions (`person is null` first key) — ascending
  would otherwise open on twelve thousand blanks.
  Pinned by [`LeadStaffInitialsTest`](/tests/Feature/Manage/Leads/LeadStaffInitialsTest.php).
  ⚠️ **The Rep column has no `#cell-top_staff` slot for about a day (2026-09-18).** A column reorder
  dropped the template block, and `DataTable`'s default cell printed the raw `top_staff` OBJECT —
  the whole payload stringified into a 60px cell, which the founder reported as "error code in the
  rep column". Nothing failed: a missing slot is valid Vue. Two guards now:
  `display()` in [`DataTable.vue`](/resources/js/Components/DataTable.vue) renders an object as "—"
  and `devWarn`s instead of stringifying it (every list page gets that floor), and
  [`Index.slots.test.js`](/resources/js/Pages/Manage/Leads/Index.slots.test.js) fails when any column
  in this page's `columns` array has no `#cell-{key}` slot — the seven readiness columns excepted,
  since one dynamic `#[area.cellSlot]` template serves them all.
- **An INCOMPLETE report gets no link (2026-09-19).** The founder's definition: *incomplete = no
  WhoPay result*. The Report column used to link whenever a consent was signed, so **33 of its 141
  links opened a page with no eligibility, no commitments and no quota** (the one that prompted this:
  lead 2702, signed consent, zero `wealth_whopay_reports` rows).
  `LeadsController::financialReportUrl()` now returns null unless `has_whopay_report` is true, and the
  cell falls back to its dash — its two figures come from the same WhoPay row, so link and numbers
  appear and disappear together. All 108 leads with a WhoPay row also have its `report_url`, so the
  existing `has_whopay_report` alias is the right test. The public page itself is unchanged: a
  customer who signed can still open their own link; the LIST just stops offering staff an empty one.
- **REPORT = what a bank would lend them, and how much 90% quota is left (2026-09-18).** The cell was
  a link to [the financial report](/docs/modules_handbook/manage/fpa/readMe.md); a link tells the
  reader only that a report EXISTS, and the two figures inside it are the ones that decide whether
  this lead can buy at all. It now stacks three things, from
  [`financialReportFacts()`](/app/Http/Controllers/Manage/Leads/LeadsController.php):
    - **Loan eligibility** (`Landmark` icon) — `elig_new_home_loan` off the lead's WhoPay report,
      printed short (RM 1.2m). **Zero is not blank**: it renders red as "Not eligible", because a
      bank having looked and said no is the single most useful fact in the row.
    - **90% quota left** (`TicketPercent`) — `2 − housing loans on record`, floored at 0. Malaysia
      allows 90% margin on the first two housing loans; the third drops to ~70%, which changes the
      deposit conversation entirely. It counts `type = Housing Loan` rows with an outstanding balance
      (Own + Joint), so **2 means they have never taken one**. A ticket, not a percent sign: this is
      an entitlement being spent, and "how many do I still hold" is the question a buyer asks. Amber
      at 0. The hover spells out what each count means.
    - **The link** (`ExternalLink` + "Report") — unchanged: `/financial-report/{token}`, the page the
      customer signed consent for, `@click.stop` so opening the report does not also open the lead.
  The column moved under **PORTFOLIO** with Record and Declare, where it belongs: all three describe
  what this person ALREADY holds, as against PROPERTY CLOSING, which is the deal being worked now.
  **Report and Declare sort (2026-09-18).** Report orders on the figure it leads with — the newest
  report's `elig_new_home_loan`, selected as the `sort_report_eligibility` alias and reused by the
  "no report at all" key — never on the quota beside it, which is counted out of
  `credit_facilities_json` in PHP and is not something to order 12,000 leads by. Declare orders on
  `leads.webinar_property_count`, whose bucket codes ascend with the count. Both put rows with NO
  value last in BOTH directions: `0` is a real answer on each (*Not eligible*, *None yet*) and must
  not be buried under thousands of leads that simply never answered — ascending Declare is how you
  find first-time buyers.
  ⚠️ **Record cannot sort, and that is not an oversight.** The archive is a different database on a
  different server, looked up one page at a time under audit (every lookup is recorded against the
  viewer, see [Owner Listing](/docs/modules_handbook/manage/owner-listing/readMe.md)). Ordering the
  list by it would mean 12,000 audited lookups of people nobody asked about.
- **Membership prints the number of products bought (2026-09-18).** `RM 4,299 (3)` — the lifetime
  total with the count of subscriptions beside it, tinted `bg-green-50` at exactly 2 and `bg-green-100`
  above that. One purchase is a customer; three is somebody who keeps coming back, and the money
  alone cannot separate "one expensive thing" from "three things". The tint starts at 2 because 1 is
  the ordinary case and colouring it would make the whole column green.
- **TEAM PERFORMANCE, collapsed, above the list (2026-09-18).**
  [`Partials/PerformancePanel.vue`](/resources/js/Pages/Manage/Leads/Partials/PerformancePanel.vue)
  — who ran the most 1-on-1 Zooms, phone calls and showroom visits, with its own date range
  (Today … Last month, or a custom from–to) and its own JSON endpoint
  (`GET /manage/leads/performance`, [`LeadPerformanceController`](/app/Http/Controllers/Manage/Leads/LeadPerformanceController.php)).
  JSON, not page props: changing the range or the metric must not reload a filtered, sorted,
  paginated list of twelve thousand leads. **Closed by default** and remembered per browser — this
  sits on top of a list somebody opens fifty times a day to find one person.
  **Three metrics, because they disagree and each is defensible:** Sessions (rewards reach — a
  dialler wins), Time (rewards depth), Per client (time ÷ distinct clients — how much of themselves
  one customer gets). On one real month the three put three different people first.
  The ranking is [`Src\Operations\Services\ChannelLeaderboard`](/src/Operations/Services/ChannelLeaderboard.php),
  the SAME service the Operations dashboard's Top Performer cards read, so "who is top this week"
  cannot depend on which page you opened. It also owns the channel rules (Zoom counts meetings that
  HAPPENED via `ZoomMeeting::hasHappened()`, the PHP twin of `applyHappened()` kept beside it in the
  model; ignored calls are excluded) — a caller that hydrated its rows differently still gets the
  same board.
  ⚠️ **ONLY WORK ATTACHED TO A LEAD COUNTS** (founder's rule, 2026-09-18). A session with no
  `lead_id` earns no sessions, no time and no client — it appears on no lead page, feeds no
  readiness column and belongs to no relationship, so it cannot be somebody's performance either.
  It is still SHOWN, as "N not counted — no lead linked", because a rule nobody can see is a rule
  nobody follows. The day it shipped, 207 of 372 Zoom meetings had no lead and this week's board
  went to almost all zeros — which is the true state of the data, not a bug in the board. Per-client
  is **null, not 0**, when nothing is attached: a rate with no denominator is unknown, and a zero
  would sort as if the person had been idle.
  ⚠️ **AND A COLLEAGUE IS NOT A CLIENT** (founder's rule, 2026-09-18). Staff hold lead records here
  — `UserRepository` gives every admin one, flagged `is_staff`, so they never surface as a prospect
  — which meant a team catch-up booked against a colleague looked exactly like a client meeting and
  carried its full weight: the week this shipped, the Zoom board was led by 2h 05m of one
  super-admin talking to another. A session whose lead is **anyone on Manage → People → Admins**
  (an `admins` row, or `leads.is_staff`) now earns nothing on any of the three channels, and is
  reported beside the total as "N internal" — hovering names the colleague and links to their
  Channel tab. It is kept SEPARATE from "not counted" on purpose: there is nothing to repair about
  an internal meeting, so it must not read as a mistake somebody should go and fix. The rule reads
  the LEAD, never the host.
  **Both halves of that line open (2026-09-18).** Hovering "1 client" lists WHICH customers, each a
  link into that lead's Channel tab on the right sub-tab; hovering "4 not counted" lists the sessions
  that earned nothing — title, when, how long — each linking to the recording's own list with
  `?detail={uuid}`. A number somebody disputes is a number they have to be able to walk into, and
  "not counted" is only a fair rule if the four rows behind it can be found and fixed in one click.
  `ChannelLeaderboard` builds both lists (`client_rows`, `unlinked_rows`, capped per person) so the
  links cannot disagree with the counts they hang off; the card is teleported out of the panel and
  held open while the cursor is inside it, since the whole point is to click something in it.
  ⚠️ Zoom's list link is `/manage/zoom/recordings` — `/manage/zoom/meetings` is a 404 and shipped as
  one.
- **THE ELEVEN LIVE ON THE PAGE, NOT BEHIND THE BUTTON (2026-09-19).**
  [`Partials/LeadFilterBar.vue`](/resources/js/Pages/Manage/Leads/Partials/LeadFilterBar.vue) sits
  above the table — **collapsed on every load, one click to open** (the founder's second call, the
  same day: "always default hide and only click to expand"). It is deliberately NOT remembered per
  browser: a remembered-open panel is the always-on bar that was asked to go away. Collapsing never
  hides that the list is narrowed: the toggle says "N on", and ActiveFilterChips above names each one. The founder's words: *"i wan this to show above the lead table, i dun
  wan need to always click the filter button then only show."* A drawer costs open → tick → Apply →
  read → open again, which is the wrong price for a question asked every few minutes.
    - **Status is six chips with live counts** (it is the filter every other one is combined with);
      the rest is one compact grid in the order asked for: Paid ≥ / ≤ · Booked / Converted / Dropped ≥
      · On record + Record ≥ · Declared · Report · Eligible ≥ · 90% quota · the seven readiness areas
      · Top rep · Engagement. "Clear these" appears only when something is set.
    - **It is a FORM: nothing searches until Filter is pressed** (the founder's third call that day:
      "need to click filter then only search and show result"). Every control edits a local draft,
      seeded from the APPLIED filters each time the panel opens; Filter (or Enter in any box) copies
      the draft into `filters` and runs. An edited-but-unrun panel says so ("Changes not applied yet"),
      and the toggle's "N on" counts what is applied, never the draft.
    - **Paid is five bands** (`membership_band`, `LeadQueryRequest::MEMBERSHIP_BANDS`): RM100 or less ·
      100–500 · 500–1,000 · 1,000–2,000 · above 2,000 — (from exclusive, to inclusive], among PAYERS, so
      30 + 68 + 4 + 9 + 305 = 416 = everyone who has paid. "RM100 or less" does not include the 12,276
      who paid nothing; that is `membership_max = none` in the drawer.
    - **The panel's shapes, after the founder's review of it:** Paid ≤ and Record ≥ removed;
      **Book / Convert / Dropped are exact buckets** (1 · 2 · More than 2 — `*_count`, a partition,
      so every lead with deals of that kind lands in exactly one: booked 26+1+0 = 27, converted
      16+172+9 = 197, dropped 168+18+2 = 188); **Eligible is four bands** (`eligibility_band`: under
      RM500k · 500–750k · 750k–1 mil · 1 mil+, newest report wins — 43+11+16+38 = 108 = every lead with
      a figure); **Engagement is four channels** in each column's own unit: Portal actions (1+ · 5+ ·
      20+, our bands — the founder left them to us), Zoom meeting TIME (`meeting_minutes_min`, 30+ ·
      60+; `meetings_min` counts meetings, so one two-hour consultation and one five-minute call
      both read 1 there), Zoom webinar (60+ · 120+) and Phone (15+ · 30+). The old project-engagement
      yes/no left the panel and stays in the drawer.
    - **The layout (founder's design pass, 2026-09-19): five titled rows, one grammar.** Status ·
      Readiness · Deals & portfolio · Financial eligibility · Engagement — each a plain one-line title
      on the left (`w-44`, top-aligned with its row's first line) and controls on the right. The Deals
      row is captioned by the TABLE's own band names (Membership · Property closing · Portfolio ·
      Relationship), carrying the icon that band's header wears, so the panel and the table share one
      vocabulary. Icons live on those band captions only, never on the row titles — two icon levels
      read as two hierarchies. **Financial eligibility is its own row** (Report · Loan eligibility · 90%
      quota) because it answers "can they buy at all"; **Top rep sits in the Deals row** under
      Relationship. A control that is SET (not Any) is brand-tinted, so what is on reads at a glance —
      the panel's one flourish. The action bar counts staged changes ("1 change not applied — press
      Filter"). Checked by rendering the real component in a Playwright harness with values set; the
      first render showed a wrapped two-line title, a wrapped readiness label and an "RM any"
      placeholder, all fixed.
    - **The dropdowns are [`Components/FilterSelect.vue`](/resources/js/Components/FilterSelect.vue),
      not native `<select>`** (founder: "the filter drop down design is not world class"). The OS menu
      could not be styled, ticked or given hints. FilterSelect is a button + a real menu: the current
      option is ticked, an option can carry a `hint` ("0 left · drops to ~70%"), a set filter is
      brand-tinted, and the menu flips upward when there is no room. It keeps the native control's
      keyboard contract — ↑ ↓ Home End Enter Space Esc Tab and first-letter jump — because a prettier
      menu that loses that is a regression (pinned in `FilterSelect.test.js`). The menu is TELEPORTED
      with fixed coordinates: the panel clips its corners and AppShell is a `@container`.
      ⚠️ In tests, the menu lingers a couple of frames after closing (its `<Transition>`); assert after
      a short settle, and scope option lookups to `#{id}-list`, or a test reads the previous menu.
    - ⚠️ **Every key the panel writes must also be a dimension in `Index.vue`.** useResourceIndex
      only SENDS keys it knows; a control writing an unregistered key looks like it worked and filters
      nothing. A node check counts the panel's keys against the registered dimensions (22/22).
    - **Readiness is the table's own icons**, cycling on click: once → YELLOW (signal), twice → GREEN
      (ready), three times → clear. Yellow filters to leads AMBER in that area, green to leads GREEN —
      the exact colours the column shows, so a yellow filter never returns a green tile. The staging
      above is what makes a cycling control workable at all: searching on every click would fire a
      request for a state the reader was only passing through.
      Pinned in [`LeadFilterBar.test.js`](/resources/js/Pages/Manage/Leads/Partials/LeadFilterBar.test.js).
    - **The drawer keeps the other 18 dimensions** (source, funnel, channel thresholds, dates) and
      writes the SAME keys, so a value set in either place shows in both — there is one filter state,
      two ways to reach it.
    - ⚠️ **The bar writes `filters` and calls `apply()`, never `applyFilters()`.** The latter is the
      drawer's Save: it copies the staged `draft` over `filters`, so a control outside the drawer that
      called it would have its own choice overwritten by a draft synced when the drawer last opened —
      the list comes back unfiltered with nothing throwing. Pinned in
      [`useResourceIndex.test.js`](/resources/js/composables/useResourceIndex.test.js).
    - Verified end to end through the real page: status 2,446 · paid 99–500 → 98 · converted ≥1 → 197
      · record ≥2 → 842 · declared → 456 · report+eligibility+quota → 26 · two readiness areas → 8 ·
      rep+engagement → 3 · all four together → 1.
    - **Two bugs the reorder introduced and the checks caught**, both worth knowing because neither
      announces itself: a comment containing a comma was cut mid-line (the BUILD failed — the live site
      kept serving the previous bundle, which is `live-build.sh` working as designed), and entries
      joined with an extra comma produced `},,` — a *hole* in the array, which parses fine and puts
      `undefined` where a dimension should be. Always count the dimensions after touching that array.
- **THE DRAWER NOW SLICES EVERY COLUMN THE TABLE SHOWS (2026-09-19).** The founder asked for one
  filter per thing the row prints, "starting with status". Fourteen dimensions were added to
  [`LeadQueryRequest`](/app/Http/Requests/Manage/Leads/LeadQueryRequest.php), each reading the SAME
  rule as the column beside it, so a filter can never return a lead whose own cell disagrees:
    - **`stage`** — the Status ladder itself, and the reason this was worth doing: *Quiet + Loan
      ready* is **19 leads in 6ms** (people we spoke to who went cold, whom a bank has already
      cleared). Counts ride in the drawer (Client 546 · Working 45 · In contact 255 · Quiet 2,445 ·
      Learning 3,445 · Registered 5,956) and are computed through the same SQL the filter applies.
    - **`membership_min` / `membership_max`** — lifetime money, the same sum the column prints; both
      ends take a custom amount ("higher or lower than X" was the ask), which needed the shared
      `threshold` control to learn a `compare` symbol and labelled presets — a bare `0` cannot be a
      threshold value, so "Nothing" travels as the word `none`.
    - **`lost_min`** — PROPERTY CLOSING's third outcome; Book and Convert already had one.
    - **`report` · `eligibility_min` · `quota`** — the three facts the Report column prints. Quota is
      counted in SQL with **`JSON_TABLE`** over `credit_facilities_json`, applying the rule
      `financialReportFacts()` applies in PHP (account rows · type "Housing Loan" · outstanding > 0),
      so twelve thousand leads narrow without hydrating a report.
    - **`readiness_*`** — one per area, at a chosen state. Different question from the existing
      `ready` ("green in EVERY area ticked"): this is "loan settled, cash not yet".
    - **`top_rep`** — whose hours are in the relationship. The definition moved to
      [`LeadTopRep`](/src/Lead/Support/LeadTopRep.php) so the FILTER and the column's SORT cannot
      drift; only colleagues who top at least one lead are offered.
    - **`engagement`** — has a project open at all, the question the old Status column was the only
      way to ask.
  ⚠️ **"At least N related rows" with N > 1 was a 500 until 2026-09-19.**
  `diver/Database/Query/Builder.php` table-qualifies every where clause with
  `str_contains($where['column'], '.')`, and Laravel stores `whereHas($rel, …, '>=', 2)` as a count
  SUBQUERY compared to 2 — an Expression, not a string — so it threw a TypeError. N = 1 escaped
  because Laravel turns it into a plain whereExists, which is why the drawer's "≥ 1" worked and
  nobody noticed that Portal activity ≥ 5, Zoom meetings ≥ 2, Bookings ≥ 2, Conversions ≥ 2 and AI
  calls ≥ 2 all failed. The normaliser now skips non-string columns (an expression must not be
  prefixed anyway). Pinned in [`WhereHasCountTest`](/tests/Feature/Diver/WhereHasCountTest.php) —
  and because it is Diver, the fix applies to every model in the app, not only leads.
  ⚠️ **A falsy filter value never reaches its method.** `ManageQueryRequest::applyFiltersTo()` runs
  `collect($filterable)->filter()` first, so `'0'` is dropped before dispatch — which is why
  "Properties owned (at most) → None — first property" **did nothing at all** until this change,
  despite the method guarding `$n >= 0`. Zero now travels as the word **`none`** (8,876 leads own
  none), and `membership_max` uses the same sentinel for "Nothing paid".
  - **`archive` · `archive_min`** — the "On record" column, which needed a TABLE to get a filter at
    all. The archive is a separate MySQL database on a separate SERVER (see
    [Owner Listing](/docs/modules_handbook/manage/owner-listing/readMe.md)), so nothing can join it:
    [`leads:refresh-owner-matches`](/app/Console/Commands/RefreshLeadOwnerMatches.php) calls the SAME
    matcher the column renders and mirrors its answer into **`lead_owner_matches`** (one row per lead,
    upserted; a lead that stops matching has its row REMOVED, or the filter keeps returning somebody
    the column no longer shows). First run: **12,692 leads scanned, 2,233 matches, 13.2s**; scheduled
    hourly beside the readiness refresh. On today's data: found 2,233 · not found 10,459 · ≥2
    properties 842 · ≥5 properties 133 — and **≥2 properties AND Quiet is 180 leads in 15ms**, which
    is the list somebody would actually work.
    ⚠️ **A withheld figure is NULL, never 0.** A switchboard number's holdings are not reported, so
    `archive=yes` includes it and `archive_min` excludes it: we do not know that it owns nothing, and
    we certainly do not know it owns hundreds. Pinned in
    [`LeadArchiveFilterTest`](/tests/Feature/Manage/Leads/LeadArchiveFilterTest.php).
    The mirror is a CACHE, so the same live-vs-cache gap applies as everywhere else: a match found
    since the last run shows in the CELL before the FILTER can see it.
  The stage/filter pair is pinned by
  [`LeadStageFilterTest`](/tests/Feature/Manage/Leads/LeadStageFilterTest.php), which filters by each
  level and asserts every returned row's BADGE prints that level — the twin check that catches
  somebody editing one path and not the other.
- **QUICK FILTERS — removed (2026-09-19).** The one row of toggles added on 2026-09-18 (Pipeline
  New / Qualified / Converted / Lost, Engaged 7d, No touch 90d+, Has booking) was taken out at the
  founder's request once the LeadFilterBar existed. Its Pipeline chips filtered `leads.status`,
  which the Status column no longer shows, and every other question it answered is on the bar. The
  server filters it used (`engaged_days`, `quiet_days`, `booked_min`) are unchanged and still in the
  drawer; only the shortcut row went.
- **STATUS = where the PERSON stands, on six measured levels (2026-09-19).**
  [`Src\Lead\Support\LeadStages`](/src/Lead/Support/LeadStages.php) replaced what the column showed.
  The old badge was `leads.status`, a roll-up of the lead's per-project engagements
  (`SyncLeadStatusFromEngagements`), and the founder's question — *"how is the status column decided,
  always all new?"* — had a arithmetic answer: **12,316 of 12,691 leads read New**, because the roll-up
  deliberately leaves a lead with NO engagement untouched and only 406 leads have one. **Contacted read
  0** (no engagement ever sits at Contacting, so the value is unreachable), and **148 people were marked
  Lost** because one project ended. Attending a webinar, replying on WhatsApp, sitting through a Zoom
  consultation and buying a membership all left it untouched.
    - **The ladder, highest rung wins:** Client (paid us — a Converted engagement or any
      `member_subscriptions.price_paid > 0`) · Working (an engagement that is neither Converted nor Lost)
      · In contact (two-way AND within 30 days) · Quiet (real contact, but one-way or older) ·
      Learning (webinar / lesson / video / portal activity AND no contact at all) · Registered (nothing).
      On today's data: **546 · 55 · 253 · 2,440 · 3,445 · 5,952.** "Quiet" is the find — the largest
      reachable group on the list, and it used to read New.
    - **The top of the ladder reads LOYALTY (founder, 2026-09-19 — "as CEO, when I see this, I know who
      my loyal customers are").** The single Client rung became the ladder's top six, ten rungs in all:
      **Loyal** (3+ completed purchases) · **Repeat** (2) · **Client** (1) · Working · **Dropped** ·
      **Member** · In contact · Quiet · Learning · Registered — today 9 · 16 · 172 · 59 · 140 · 296 ·
      223 · 2,433 · 3,440 · 5,904, summing to every lead exactly once. Loyal / Repeat / Client are one
      green at three strengths (`emerald-solid` / `-strong` / `emerald`), so loyalty is a VALUE
      progression down the column rather than three colours to learn.
      **Dropped is "booked, then fell through"**, in the founder's words "customers who had high trust
      in us, then only book, but in the end failed for a reason": a LOST engagement that carries a
      `bookings` row — 200 of the 211 lost deals do; the other 11 were lost before any booking, and a
      lead who said no is not a customer who said yes. The row carries `was_booked`
      (`withExists('bookings')` on `propertyRecords`) so the badge and the SQL twin read the same fact.
      `lost_stage` was NOT usable: legacy imports stamp it "appointment" on deals that had bookings.
      **Member** exists because clients are now defined by property closing: paying for membership is
      a different claim from buying through us, and 296 people make only the first.
      Three orderings that were judgement calls: Working (a live deal) outranks Dropped (history), so a
      dropped customer being re-worked reads Working with "1 booking fell through" in the hover;
      Dropped outranks Member (a booking fee is a bigger commitment than RM99); and any conversion
      outranks everything, open deals included. In SQL, `below()` applies "and on no rung above" in
      ladder order, so a new rung is one line there rather than an edit to every rung under it.
    - **"Working" became Booked + Prospect (2026-09-19), eleven rungs in all.** The founder felt the
      word "does not represent that a member has booked a property but it is in progress" — and the
      data agreed: of 59 Working leads only **22 had a booking in progress**; 37 had an open deal and
      had never booked (29 New · 7 With Closer · 1 Appointment Set). Renaming the rung would have
      mislabelled those 37, so it split: **Booked** (rank 8, orange — read from the row's own Booked
      column, so badge and number cannot disagree) and **Prospect** (rank 7, brand — an open deal
      before any booking). Today: Loyal 9 · Repeat 16 · Client 172 · Booked 22 · Prospect 37 · Dropped
      140 · Member 296 · In contact 223 · Quiet 2,433 · Learning 3,440 · Registered 5,904.
    - **Every badge wears an icon, and the palette is the founder's.** Loyal is GOLD with a crown —
      the only gradient on the page, so it is the one thing that stands out; Repeat is solid green with
      white type and Client the same green, light, so bought-twice reads heavier than bought-once
      without a second hue to learn; Booked orange; Quiet dark grey (amber shouted "warning" about
      2,400 people who are simply not talking to us). Colours AND icons live in
      [`utils/leadStages.js`](/resources/js/utils/leadStages.js), read by the table badge, the filter
      chips and the guide page — three colour maps used to mean a status could look three ways.
      ⚠️ A solid badge's pale icon vanishes on an UNSELECTED chip's white ground, so each tone has a
      separate `off` icon colour; a rendered screenshot caught that, no test would have.
    - **The guide page opens with a PYRAMID, and it is the one place the page reads leads (2026-09-19).**
      Founder: *"design a hierarchy visualization … so that a closer once sees it immediately understands; Loyal
      is the king and at top … show how many % for each level. Client & Dropped should be the same level, so that
      we can see the conversion rate."* → [`Partials/StagePyramid.vue`](/resources/js/Pages/Manage/Leads/Partials/StagePyramid.vue).
        - **`tier` ≠ `rank`.** `rank` (1–11) stays the RESOLVER's order. `tier` (1–10) is the level a person
          reads: Client and Dropped share tier 8 — both paid a booking fee; one purchase completed, one fell
          through. Dropped's `rank` stays below Booked/Prospect, because someone whose booking dropped and who is
          booking AGAIN must read Booked — what needs doing today. The guide cards are numbered by `tier`.
        - **Width is NOT headcount.** Registered is 650× Loyal; to scale, the pyramid would be a line with a dot on
          it. The silhouette is fixed and the truth is printed beside each level — count, % of list, and a bar on
          ONE linear scale. `LeadStages::BANDS` groups eleven statuses into five questions on the left rail.
        - **Booking conversion = `LeadStages::conversion()`**: bought = Loyal + Repeat + Client (not the Client
          level alone — our best customers belong in our own success rate) ÷ (bought + Dropped). **58.5%** on
          2026-09-19 (197 vs 140). Booked (22) is shown beside it and kept OUT of the rate: no outcome yet. `rate`
          is `null`, never 0, when nothing has been decided — 0% would read as total failure. People, not deals.
        - **Counts are the viewer's own** — `LeadStages::counts($user)`, the Status filter's SQL under
          `LeadVisibility`, now the single source for the list's chips too (it was inline in `LeadsController`).
          A closer sees the shape of THEIR leads, and each card's "View these N leads" lands on exactly N.
        - ⚠️ **Responsive by CONTAINER (`@lg:` … `@5xl:`), not viewport.** The page sits beside the sidebar, so a
          1024px screen leaves the card ~700px; viewport breakpoints turned the band rail on exactly when there
          was no room, squeezing the pyramid into a tower. The trapezoid step is derived from the column's own
          width in `cqw`, so there is no resize listener. Two containers: the section (rail / numbers / apex) and
          each pyramid row (the seam pill, which cares how wide the PYRAMID is).
    - **Member is fuchsia, in three weights by what they paid (2026-09-19).** Indigo sat one step from Prospect's
      brand blue and the founder read them as one status. `LeadStages::MEMBER_SHADES`: under RM 1,500 pale ·
      RM 1,500 – 2,500 deeper · **over** RM 2,500 solid with white type (81 / 193 / 22 of 296 that day) — *"so
      that I can differentiate who are members and pay me high amount"*. Same grammar as Client → Repeat: more of
      the same thing reads heavier, not differently. It paints the badge (`forRow` returns the shade as `color`);
      it is NOT a twelfth status — level, rank and the Status filter are unchanged, and the Paid dropdown is how
      you FILTER by amount. A Client who also pays a membership keeps the Client colour.
    - **A person is never "Lost".** Deals are lost; people go quiet. Lost stays on the engagement, where
      it is true of one project and says nothing about the next — 114 of our buyers also hold a paid
      membership.
    - **It is a pure function of the row** (`LeadsController::withStage()`), computed from columns the
      reader can already see: converted projects, membership paid, open pipelines, and the Relationship
      and Understanding readiness areas. The badge therefore cannot disagree with the table around it,
      and it costs **no extra query**. ⚠️ Those row fields arrive as Eloquent **Collections**, so the
      resolver uses `collect(...)`: `(array)` on a Collection returns its private properties, which
      silently made every row a Client with an empty project name.
    - **Every badge hovers** ([`Partials/StageCell.vue`](/resources/js/Pages/Manage/Leads/Partials/StageCell.vue)):
      the facts that put THIS lead on that rung ("Converted: Peel Lane", "Paid RM 3,265 across 4
      products", "Last touch 94 days ago — past the 30-day line") and **what would move them up**, which
      is the only part of a status a salesperson can act on.
    - **The header carries a signpost** linking to `/manage/leads/stages`
      ([`StageGuideController`](/app/Http/Controllers/Manage/Leads/StageGuideController.php) →
      [`StageGuide.vue`](/resources/js/Pages/Manage/Leads/StageGuide.vue)) — each level with its
      definition, its measurable rules, the records it reads and what moves somebody up, rendered from
      `LeadStages::STAGES` so the page cannot drift from the badge. Declared before `GET {id}`, like
      `readiness` and `export`; **a new route needs `php artisan route:cache` on this box** or it 404s as
      an `{id}` uuid.
      **Redesigned 2026-09-19 (founder: "this page uiux is not nice"; "dual language, default is mandarin
      with keyword is english").** It was the pyramid, three paragraphs of doctrine and eleven stacked
      rule cards — 4,500px, so reading Dropped's rule meant scrolling away from the pyramid that explains
      where Dropped sits. Now the pyramid is the index and ONE level's rule sits beside it in a sticky panel
      (count + "View these N leads", meaning, how it is measured, what moves them up, the CRM's rule folded
      away, ↑/↓ to walk the ladder); a level is a `<button>` that emits `select`, not an anchor. The pick is
      the URL hash (`#dropped`), so a link opens on one level. Three one-line ground rules replace the
      doctrine paragraphs, and a compact table keeps the "every rule at a glance" view the cards gave.
      Side-by-side is a CONTAINER query (`@5xl`) — the page sits beside the sidebar.
      **Two languages, 中文 by default** ([`useUiLang`](/resources/js/composables/useUiLang.js) +
      [`LangToggle`](/resources/js/Components/LangToggle.vue); one shared choice per browser, `localStorage`
      `peta-ui-lang`, guarded). The Chinese for every rule lives in a **`zh` block beside the English in
      `LeadStages::STAGES` / `BANDS` / `MEMBER_SHADES`** (`label_zh`) — never in the page, which would
      drift the first time a rule was edited in one place. Keywords stay English inside the Chinese
      (Converted, Booked, engagement, booking, loan) and the status `name` is never translated: they are
      the words on the badges and filters the reader goes looking for; `zh.label` is only a gloss beside
      the name. [`LeadStagesTranslationTest`](/tests/Unit/Lead/LeadStagesTranslationTest.php) pins every
      `zh.kpi` one-for-one with the English rules, `next` present in both or neither, and no translated
      `name`. The list's StageCell hover is still English-only — not yet on the switch.
      **The people are on the page too (2026-09-19, founder: "show who are the leads and its details,
      rather than open again another page").** Under the pyramid,
      [`Partials/StageLeads.vue`](/resources/js/Pages/Manage/Leads/Partials/StageLeads.vue) lists the chosen
      level's leads 20 at a time from `GET /manage/leads/stages/{stage}/leads`
      (`StageGuideController@leads`, JSON, route declared before `GET {id}`). The rows are the Status
      filter's own SQL (`LeadStages::applyFilter`) under `LeadVisibility`, so the list's total IS the
      pyramid's count — verified on all ten populated levels — and each row's reason comes from
      `LeadStages::forRow()` fed the same record shapes the list feeds it (bought / booked / lost with
      `was_booked` / pipelines / membership / readiness), so it is the badge's own reason; every row on
      every level resolved to the level it was listed under. A row shows name, phone, registered date,
      that reason, deal chips (bought, booked, booking fell through, lost before booking, open), membership
      paid, owner and the seven readiness dots, and opens IN PLACE to the full facts, each deal's unit /
      price / booking date and the membership lines. "Preview the full record" opens the shared
      `LeadDetailModal` over this page (`useLeadModal`); only "Open in a new tab" leaves it. Ordered by
      what the level is about — the deal levels by latest deal activity (correlated subquery, never a
      join), Member by amount paid, the rest newest first — with `leads.id` as the tiebreak. Search matches
      name, e-mail or phone digits. ~350 ms a page. The panel's "View these leads" now scrolls to this list
      instead of leaving for the Leads list; the list header keeps a link to the Leads list for filtering
      and export.
    - **The column does not sort, on purpose.** The value is computed from the row, so the only thing the
      server could order by is the legacy `leads.status` the badge no longer shows — the same lie the Rep
      column refuses. Sorting and (more usefully) FILTERING by level need the ladder materialised the way
      READINESS is cached in `lead_readiness`; that is the open follow-up.
    - The drawer's old dimension is renamed **"Pipeline status"**. It still filters
      New/Contacted/Qualified/Converted/Lost, which is a real question about a DEAL — just not what the
      Status column answers any more. `leads.status` itself is untouched: `RevenueIntelligenceQueue`,
      `JourneySync` and the WhatsApp `AudienceResolver` still gate on it.
    - Pinned by [`LeadStagesTest`](/tests/Unit/Lead/LeadStagesTest.php) — a unit test with no database,
      because the resolver is a pure function of a row.
- **READINESS band = seven areas, one icon each, coloured by OUR OWN rules (2026-09-18).**
  Need · Relationship · Understanding · Loan · Cash · Decision-maker · Property fit, banded left
  of PROPERTY (beside the identity columns — a status colour nobody can see without scrolling
  sideways is not a status colour). Three values, and colour carries the whole message: **green =
  the area is settled**, **amber = something is on record but not enough**, **grey = nothing on
  record** — not an error, and the state most leads are in, so grey is deliberately quiet (no tile)
  and only a signal draws the eye.
  [`Src\Lead\Support\ReadinessColumns`](/src/Lead/Support/ReadinessColumns.php) owns the rules,
  one batched query per area for the page (never one per row, the shape `LeadOwnerMatch::forPage()`
  set for PROPERTIES OWNED): **12,307 leads in 5.2s** measured over the whole table, so a page costs
  ~40ms.
    | Area | Green | Amber | Reads |
    |---|---|---|---|
    | Need | a completed Property Match form, or a wealth plan with a goal | the webinar poll only, or a plan with no goal | `property_match_submissions` · `wealth_plans` · `leads.webinar_property_count` |
    | Relationship | two-way **and** something in the last 30 days | any real contact | WhatsApp (1:1, non-sandbox) · `call_recordings` · `zoom_meetings` · `f2f_recordings` |
    | Understanding | 90+ minutes of webinar, or a finished lesson | some attendance, a funnel video, portal activity | `zoom_webinar_attendances` (summed from raw segments) · `lms_lesson_progress` · `funnel_video_views` · `activity_logs` |
    | Loan | **a bank has seen them** — LO signed, a CCRIS report, a full financial profile | their own figure only | `bookings.lo_signed_at` · `wealth_whopay_reports` · `fpa_analyses` · the Property Match `loan` answer · `wealth_plans.total_loan` · `property_analyses` |
    | Cash | real figures on file | an income bracket **we estimated** | `fpa_analyses` · `lead_enrichments.income_bracket` |
    | Decision-maker | a joint applicant named on the financial profile | a profile without the second party | `fpa_analyses` (the only structured source) |
    | Property fit | a booking exists | a pipeline record, a match result, units opened | `bookings` · `engagements` · `property_match_submissions.result` · `lead_project_views` · `lead_floor_plan_views` |
  Three rules that are not cosmetic, each of them the reason a column exists at all:
    - **No AI reading may set a colour.** A reading proposes what somebody SAID; these columns report
      what the CRM can SHOW, so the colour is reproducible by anyone who opens the lead and
      re-reading the same conversation can never move it. `LeadReadinessColumnsTest` has a case
      that stores a reading claiming full readiness and asserts the cells stay grey.
    - **An estimate is never green** (Cash's income bracket is *our guess about them*), and **a
      figure the customer states about themselves is never green until somebody checks it** (Loan is
      amber on "I can borrow RM650k–850k", green on a Letter of Offer). This is the distinction the
      whole band rests on.
    - **Every cell carries `why`** — the fact that decided it, in the record's own words ("watched
      214 min", "LO signed", "two-way WhatsApp, 2d ago") — in the tooltip. A colour whose reason is
      not on screen is a colour nobody trusts twice. The band label is the legend button, and the
      legend prints the RULE table above from `ReadinessColumns::RULES` (sent as a prop, never
      re-typed in the page), so a reader can check a colour rather than trust it.
  Today's distribution, honestly: Understanding 3,047 green / 2,463 amber, Relationship 609 / 2,584,
  Property fit 361 / 82, Need 77 / 965, Loan 9 / 72, Cash 0 / 1,830, Decision-maker 0 / 2. **Cash and
  Decision-maker are grey for nearly everyone because the CRM has no field for them** — they are said
  out loud and land only in a conversation. That is the true state of the data; a rule invented to
  make the column look busy would be worse than the grey. None of the seven sorts: the inputs are
  seven different tables, so there is no column to `ORDER BY`, and an arrow that ordered a subset of
  the rows would be a lie (the same reason PROPERTIES OWNED carries none).
  **Every column header is a link into the rule book** (2026-09-18): the icon opens
  `GET /manage/leads/readiness` — [`ReadinessGuideController`](/app/Http/Controllers/Manage/Leads/ReadinessGuideController.php)
  → [`ReadinessGuide.vue`](/resources/js/Pages/Manage/Leads/ReadinessGuide.vue) — anchored on its own
  area (`…/readiness#cash`), which scrolls that card into view and rings it, so the reader lands on
  the answer to the column they clicked. The band label opens the same page unanchored. The page is
  rendered FROM `ReadinessColumns::RULES` (`asks` / `ready` / `signal` / `source` / `example`), never
  a second copy of the rules, so the explanation cannot drift from the behaviour; it reads no lead
  data at all, so there is nothing to scope. It carries a `PageHeader` back link resolved by
  `ResolvesBackUrl` — the reader returns to the list **with its filters and sort intact**, because
  they clicked a column header, they did not leave. Declared BEFORE `GET {id}` (§14: a bare
  `readiness` segment is otherwise matched as an `{id}` uuid), and the route cache must be rebuilt
  for it to resolve on this box. It replaced a legend modal on the index — one explanation, in one
  place, with room to teach rather than remind. Per-area COUNTS were deliberately left off: the
  honest ones are visibility-scoped per viewer and take seconds to compute, which is a report, not a
  help page.
  **Reading a colour, in three depths (2026-09-18).** A cell's HOVER opens a card that is a
  BRIEFING, not a verdict: the fact that decided the colour (`why`), then every supporting fact the
  records hold for that area (`facts` — "WhatsApp: 288 in / 191 out · last 3d ago", "Webinars: 14 ·
  1,487 min total", "Booked: Sutera KLCC · unit A-12-3 · SPA RM 600,000", "Pipeline: Binastra
  Cochrane (With Closer), Sutera KLCC (Lost)"), and — only when the area is not green — **what would
  turn it green**, read from `RULES[area].ready` rather than restated in words of its own. The point
  is that an agent should not have to open the lead to know what to open the call with. Every line is
  read off a record; nothing is inferred, summarised or scored. Teleported to `<body>` — the table
  scrolls sideways and would clip it. A column ICON's hover names the area and the question it answers; CLICKING it
  opens just that column's rules in a modal (`@click.stop`, or the click would sort the column
  instead). The BAND label opens the whole rule book as its own page. Three questions — *why this
  one?*, *what does this column mean?*, *how does the whole thing work?* — and each is answered
  without leaving more of the list than it has to.
  **All seven columns sort, green first (2026-09-18).** They order by `lead_readiness`, a CACHE of
  the same rules ([`LeadReadiness`](/src/Lead/LeadReadiness.php), written by
  [`LeadReadinessRepository::sync()`](/src/Lead/Repositories/LeadReadinessRepository.php) from
  `ReadinessColumns::persist()`), because ordering 12,000 leads needs every lead's answer at once
  and a page cannot have it. `state` is the RANK (3/2/1) so "green first" is a plain `ORDER BY …
  DESC` and a lead the refresher has never seen sorts last as NULL — *not computed* is not a
  signal. The order is a **correlated subquery, never a join** (§14: seven rows per lead would
  multiply the paginator's count by seven), and the table carries a covering index
  `(lead_id, area, state)` — without `state` in it the sort costs an extra row read per lead
  (measured 585ms → 85ms). `leads:refresh-readiness` rewrites the whole table in ~15s and runs
  **hourly** (`app/Console/Kernel.php`); the CELLS stay computed live, so the only thing that can
  be stale is an ordering, never a colour.
  **The filter drawer was reviewed against the columns (2026-09-18)** and seven dimensions added,
  one per band that had none: **Ready in** (multi — green in EVERY area ticked, an AND, because
  "ready in Cash *and* Loan" is the question somebody about to call has), **WhatsApp messages**,
  **AI calls**, **Bookings**, **Conversions**, **Last engagement** (within 7/30/90 days — an OR
  across the same five channels the column reads, which is "the latest of them is within N days"
  without computing the latest), and **They said they own** (the webinar poll bucket — NOT
  `properties_owned`, which `properties_min/max` already filter; the two are different claims and
  the band shows both precisely because they disagree). Every threshold reuses the column's own
  rule (bookings by status constant, AI calls `applyDialledByAi`, WhatsApp 1:1 non-sandbox), so a
  filter can never return a lead whose own column reads zero. **Still unfilterable on purpose:**
  the owner ARCHIVE column, which lives in a different database on a different server — there is no
  SQL that can reach it, and a filter that silently ignored it would be worse than none.
  **Two toolbar buttons were removed (2026-09-18):** Lifecycle (an explainer modal) and Distribution
  Setting (a link into Lead Distribution, which keeps its own sidebar entry). The toolbar is for
  acting on the list in front of you.
  **Cost of the briefing:** the facts come from the queries the rules already run (plus the project
  names behind bookings and pipeline records, which is what makes those lines usable) — **~91ms for a
  25-row page**, 33 queries. `Schema::hasTable()` answers are memoised per request AND cached in
  Redis for 10 minutes: the rules guard a dozen tables, and `information_schema` is a network hop
  each time (145ms → 91ms). The 10 minutes is the window in which a table created by a fresh
  migration would still read as missing — short enough to heal itself without anybody clearing a
  cache.
  **The table itself is `compact` (2026-09-18).** `DataTable` grew a third density below `dense`
  (`px-2 py-1`), and the Leads identity cell put phone and email on ONE line instead of two — three
  lines per row was the single biggest thing between an operator and the next lead on screen. Action
  buttons and the readiness tiles shrank to match. Text stays 12px and every control stays tappable:
  rows lost their breathing room, nothing lost its size.
  **Language:** this band speaks ENGLISH like the rest of the Manage portal — the facts themselves
  are machine-built English strings ("watched 214 min"), so a Chinese chrome around them would put
  two languages in one card.
  Pinned by [`LeadReadinessColumnsTest`](/tests/Feature/Manage/Leads/LeadReadinessColumnsTest.php)
  (the rules, the guide rendering from the constant itself, the back link preserving the list's
  query, green-first sorting, an uncomputed lead sorting last while its cell still reads live, the
  AND across ticked areas, and the new filters matching their columns) and
  [`ReadinessCell.test.js`](/resources/js/Pages/Manage/Leads/Partials/ReadinessCell.test.js).
- **Property band = Book / Convert / Lost + Total (2026-09-17).** One `Lead::propertyRecords()`
  relation (engagements at `STATUS_BOOKED` / `STATUS_COMPLETED` / `STATUS_LOST`) is split by the
  status CONSTANT in `LeadsController::propertyRecords()` into `property_booked` /
  `property_converted` / `property_lost`; each column sorts on its own status-scoped `withCount`.
  A fourth **Total** column (bold, light-teal tinted, no hover — the three beside it list the projects) is the sum of
  the three arrays' lengths, computed in the page; it sorts on `property_record`, the old single
  Property column's key, which already ordered by that sum, so links from that era still work.
  The count is coloured per column — Convert green, Lost red, Book neutral — via `PropertyCell`'s
  `countClass`; hovering lists that column's projects only.
- **Phone Call / Showroom F2F / AI Call print "duration (count)", like Zoom Meeting (2026-09-17).**
  Each is a `withSum` of `duration_seconds` + a `withCount` on the same relation
  (`call_recordings_count`, `f2f_recordings_count`, `ai_calls_count`), and the cell renders on the
  COUNT — `0s (3)` for three calls with no talk time, `—` only when there were none. AI Call counts
  and sums only calls the AI really dialled, via `AiVoiceCall::applyDialledByAi()` (AI channel, lead
  source, `refusal_reason IS NULL`); the latest-attempt chip (`latestAiCallPerLead`, also AI channel
  only) sits under the figure and still shows a refusal as "Not called". All three sort on duration
  then count, so AI Call is sortable now (`ai_call` key).
- **Every column sorts highest-first on the first click (2026-09-17).** The table passes
  `DataTable`'s opt-in `desc-first`, so a header click cycles desc → asc → off (the default is
  asc → desc → off): Total / counts / money / durations show the biggest on top, dates the newest.
  Other tables keep the default.
- **The table freezes its header and scrolls sideways from the top (2026-09-17).** `DataTable`
  runs with `sticky-header` + `top-scrollbar`: the table is its own scroll area capped at
  `max(360px, calc(100vh - 350px))`, so the header rows stay in view and the bottom scrollbar stays
  on screen, and a mirrored scrollbar above the header scrolls it too. Change the 350px if the page
  chrome above the table grows, or the bottom scrollbar falls below the fold again.
- The controller transforms each lead with `with(['user.profile', 'leadFunnels.funnel'])` (no N+1): name/email/phone from the profile, a **`funnels`** chip array (name + source per registration) for the list column, and a **`registrations`** array (full per-registration attribution) for the expand row + Show "Attribution" tab.
- **Edit** writes contact (name, email, phone) to the linked account/**profile** via `UserRepository` (`LeadsController::update`); **status is not edited here** — it is a synced roll-up (see below). **Create** (admin) records the chosen source as an **un-funneled registration** (`LeadFunnelRepository::attach($lead, null, …)`).
- **Lifecycle is now per project.** Each project a lead is worked on is a separate **engagement** (Caller → Closer → Follow-Up), managed on the Show **"Pipeline"** tab — see [Engagements & Bookings](/docs/modules_handbook/manage/engagement/readMe.md). `leads.status` (the flat 5-value enum) is kept only as a **synced roll-up** of those engagements (via `App\Actions\SyncLeadStatusFromEngagements`); it is shown as a **read-only badge** on the list and is no longer edited inline. The list's **Pipelines** column (2026-09-17) shows each engagement as project + status chip — the same shape as Sales → Property Match (`Engagement::ALL_STATUSES`, engagements + `project.catalogProject` eager-loaded for the page) — and links to the Show "Pipeline" tab; "—" when none. Beside it, **Financial Report** shows ✓ when the lead has a WhoPay report with a link (`has_whopay_report`, a correlated EXISTS on `wealth_whopay_reports`, admin-pasted or portal-saved) and "—" otherwise — see [FPA](/docs/modules_handbook/manage/fpa/readMe.md). Neither column is sortable. (The edit form's status field remains for legacy leads that have no engagement.)
- **Export** is one row **per registration** (`LeadsExport` flattens `leadFunnels`), with a Funnel column, plus **`Agent verdict`** / **`Fake verdict`** TEXT columns carrying the resolved LABEL (the "Fake?" hedge for an AI guess, `''` when unjudged — see the shared quality-state resolver bullet above).
- **Import leads from CSV/Excel** (the "Import CSV" button → shared `ImportCsvModal`, `mode='leads'`). A **preview** (`importPreview` → `BulkMemberImportAction::preview`, MODE_LEADS) is run on upload and **writes nothing** — the modal (a wide `4xl` dialog) shows the **detected columns**, an **example file** to download, and a **count summary** (`To create` = new person/lead · `Already exist` = matched an existing lead by email or phone, only blanks filled, no duplicate · `Needs review` = matched an existing person by ONE key but the OTHER key (phone/email) in the file **differs** from what is on file, and that new value belongs to nobody else → never silently trusted; the admin **decides per row** (see below). **Order matters (fixed 2026-07-17):** every *"nothing to action"* verdict — a staff match, and **already an active member of this tier** — is now decided BEFORE the needs-review flag, in **both** `classify()` and `applyRow()`. They used to sit after it, so someone already enrolled whose CSV row carried a different email/phone was dragged into *Needs your decision* and counted on the confirm button — for a question (*enrol them?*) that had no meaning for them. Nothing was ever written wrong (`apply()` still reported `already_member`), but the count disagreed with the outcome and the admin was asked to settle something already settled · `Email/phone mismatch` = the two keys belong to two different people → skipped · `No email/phone` / `Skipped`). The counts are split into **four groups** — **Will be imported** (create / matched / unmatched), **Needs your decision** (needs-review), **Already in the system** (existing / already-members, a no-op), and **Skipped — will not be imported** (mismatch / no-email-phone / staff-or-toggle skip) — so the admin sees at a glance what happens. Each count is an **expandable section** carrying a one-line meaning under its label; click it to see the actual rows behind the number (file line, name, email, phone; + amount in enrol mode; capped at the first 300 rows per category), and each has a **Download CSV** button that exports **all** of that category's rows (UTF-8 BOM so Excel opens it cleanly). A **Needs review** row asks the admin ONE question — *is this the same person?* In **enrol mode** each flagged row gets a **checkbox** (plus a **Select all**): ticked → the row is processed as a normal match and the person is **enrolled using the email/phone already in the system**; unticked (the default) → skipped. The cell labels both values explicitly — the stored one *"in the system · will be used"*, the CSV's one struck-through and *"in your CSV · ignored"*. (Copy note: never write **"on file"** here — on a screen about uploading a file it reads as *your* file and means the exact opposite. Say **"in the system"** vs **"in your CSV"**.) Ticked rows ride the same "enrol existing leads" toggle and are added to the confirm count; the card shows *"N of M ticked to enrol"*. In **leads mode** there is no enrolment, so a flagged row is **informational only** — listed and downloadable for a human to reconcile. Only the ticked **row indexes** are posted (`review_confirmed`, one JSON array so a big flagged set can't silently blow PHP's `max_input_vars`); no contact value is ever sent, and a confirmation aimed at a row that is not flagged is inert. The diff is also written into the category's CSV export (`old → new`).
  - **Conflict rows can optionally be AUTO-MERGED (added 2026-07-21).** An `Email/phone mismatch` row (the email owned by one account, the phone by another) is skipped by default — but the leads-mode preview shows a **"Merge the N mismatched pairs"** tickbox (off by default) plus, per row, both owners and **which side survives**. Ticked → `apply(..., $mergeConflictsBy)` collapses each pair through the SAME machinery as the manual review modal — `LeadRepository::detectMergePair` → `pickMergeSurvivor` (richer by `accountRecordScore`; tie → the older account) → `createMergeRequest` → `mergeVerifiedPair` — so every bulk merge leaves the standard `verified_identity_pairs` audit trail (`reviewed_by` = the importing admin, `note = 'auto: import (conflict tickbox)'` — since 2026-07-23 every merge stamps a provenance note) and the survivor keeps everything + both keys. **Customers only:** a pair with a staff/privileged side is never bulk-merged (`pickMergeSurvivor` returns null; the row stays a skipped conflict, tagged "staff — never auto-merged") — those belong in the [Merge Requests queue](/docs/modules_handbook/manage/people/merge-requests/readMe.md), where a human confirms and the staff side is forced to survive. A merged row then continues as a normal match on the survivor (backfill, account manager, profile extras) and reports the new apply outcome **`merged`** (flash: "N duplicate pairs merged into one account"); the post-merge changed-contact gate is skipped (the survivor legitimately keeps its own keys). The conflict CSV download gains `Email owner / Phone owner / Survivor if merged` columns.
  - **An import NEVER edits an existing lead's email/phone — by design.** There is no overwrite path at all: bulk CSV is a blunt instrument, and one typo'd cell would silently corrupt a real customer's identity key, while enrolling the wrong person is trivially reversible. Fixing a genuinely changed detail is a per-person edit on the lead itself; the `Needs review` **CSV download is the worklist** for exactly that. The junk floor (a phone cell must be ≥ 7 digits, the same rule `has_identity` uses) keeps a placeholder like `"123"` from even being reported as a change.
  - **Phone equality has two strengths, on purpose** (`PhoneNumber`): `sameNumber()` (strict, canonical E.164) **binds** an identity — `matchUserByPhone` keeps it, because a loose shape is not globally unique and an unverified match could bind a *different* person. `sameNumberTolerant()` (canonical **or** any shared `candidates()` shape) is for **fail-closed** decisions — `phoneOwnedByAnother()` (refuse more often) and the changed-contact detector (flag less often). The strict form cannot round-trip a **bare-national** stored phone (`123456789` vs `60123456789`), which `candidates()` explicitly lists and the `whereIn` *does* fetch; verifying the ownership gate with it would discard a real owner, report the number free, and let one real number land on two profiles (`backfillContact` shares that gate). The confirm button states exactly what will happen (e.g. **"Create 148 leads"** / enrol mode **"Enrol 108 members"**). When the preview leaves **nothing to action** (every row already exists / all skipped) the button is **disabled** and a note explains why ("Every person in this file is already a lead in the system…") — no pointless write. Otherwise Confirm → `import` → `BulkMemberImportAction::apply` and a flash of `{created} created, {existing} existed, {skipped} skipped`.
  - **Expected columns** (auto-detected by alias via `MemberRowMapper`, any order/name): **email** (email/e_mail/mail/email_address), **phone** (phone/mobile/hp/handphone/contact/whatsapp/…), **name** (name/full_name/customer). A **header row is required**; each row needs at least an **email OR a phone** (≥7 digits). Phone accepts any format (symbols stripped) and matches an existing person **tolerantly** across shapes (`0123…` = `+60…` = `60…`) via the identity gate above — so a re-import never duplicates. Staff/admins are never turned into a lead. (The members importer reuses the SAME modal with `mode='enrol'` and two extra columns — `amount`, `paid_at`.)
  - **Optional profile columns (leads mode only, added 2026-07-21)** — a legacy CRM export often already carries more than identity, so the leads importer also accepts: **created_at** (aka `registered_at` / `signup_date`) → backdates the lead via `LeadRepository::backdateCreation()`, which moves its `lead_funnels.registered_at` with it so the two can never disagree. ⚠️ It applies **only to a lead the row just created** (`LinkResult::personExisted === false`) — re-dating someone already on record would rewrite real history, and a re-import would do it again. A **future** date is rejected outright (it would pin the row to the top of every date sort forever), and `users.created_at` is deliberately left at the real import time. Without this column a file spanning years collapses onto the day of the upload, which is what the Leads index "Created At" column and its sort read (`LeadsController` row mapper). Also: **property_own** (aka `properties_owned` / `property_count`) → `leads.properties_owned`; and an intelligence block → `lead_enrichments`: **intel_occupation** → `estimated_occupation`, **intel_income** (`low`/`mid`/`mid_high`/`high`) → `income_bracket`, **intel_summary** → `profile_summary`, **intel_hometown** → `hometown_note`, **intel_ip_location** → split into `geo_city` / `geo_region` / `geo_isp` (+ `geo_country`/`_code`) by `App\Support\MemberImport\ImportedLocation`. Unlike the canonical columns these are surfaced by `MemberRowMapper::detect()` **only when the file actually has them** (`EXTRA_FIELDS`), so the members import is not cluttered with columns it has no use for. Three rules make them safe: every write is **fill-blank-only** (a re-import, or a file that disagrees with a hand-correction or a live enrichment run, never overwrites), a cell reading `Unknown`/`N/A`/`-` is stored as **NULL** rather than as that word, and `0` properties is kept as `0` (a real answer) while a missing cell stays `NULL` (never asked). `ImportedLocation` finds the state by matching each fragment against the **known Malaysian state list** (`enrichment.income.state_median`) instead of trusting a separator, so the half-dozen export formats (`City, State (ISP)` / `via ISP` / `; ISP: X` / `- ISP`) all resolve — and resolving the state earns the free **DOSM area-income prior** the live pipeline would have derived. `geo_ip` is deliberately left NULL: that blank is what marks a location as imported rather than IP-derived. No `status` is written, so the profile honestly stays *Pending* until enrichment actually runs.
  - **Scale note (added 2026-07-21 after a production timeout).** Both preview and apply run **synchronously in the HTTP request**, row by row (~35–48 queries per row; a ticked merge adds a full account-merge per conflict pair). PHP's default 30-second budget died mid-apply on a 500-row production chunk, leaving a partial (but safe — the import is idempotent, a re-upload reports the finished rows as `existing`) write. `ParsesMemberImport::parseImport` therefore lifts `set_time_limit(0)` / `memory_limit` for import requests. The **web-server proxy timeout still applies** (nginx defaults ~60s) — it cuts the *response*, not the PHP run — so big files should still go up in **chunks of a few hundred rows** — or use the CLI twin: **`php artisan leads:import {file-or-folder}`** ([app/Console/Commands/ImportLeadsCsv.php](/app/Console/Commands/ImportLeadsCsv.php)) runs the SAME parse → `BulkMemberImportAction` pipeline with no time limit; a folder is processed file-by-file in name order, `--dry-run` previews, `--merge-conflicts --reviewer=<admin email>` enables the conflict auto-merge (the reviewer is stamped on `verified_identity_pairs`), `--yes` skips the prompt. Idempotent — a folder may safely contain chunks already imported through the browser.
  - **Assign the whole batch to one account manager (optional).** The import dialog carries a single **account-manager** picker (`assignableAdmins` prop, searchable) — the chosen admin becomes the account manager for every imported lead that **does not already have one** (existing leads with a manager are **left untouched**, so a re-import never silently reassigns). `LeadsController@import` resolves the uuid through `assignableManagerQuery` (in-scope active staff only) and threads the id into `BulkMemberImportAction::apply(..., $accountManagerId)`, which fill-blank-assigns via `LeadRepository::assign()`. The membership **enrol**-mode import does **not** show this picker (managers are a lead concept, set once).
- **Account manager (one per lead).** Each lead has a **single** account manager — a manage-portal staff member stored in `leads.assigned_admin_id` (`Lead::assignedAdmin()`, nullable = unassigned). It is the one person who looks after that lead across everything, **including all their memberships** (the [Members](/docs/modules_handbook/manage/membership/members/readMe.md) module reads it **read-only**). Set it from the **identity header** on the Lead Show page, the **Account Manager** column / row action on the Leads list, or — since 2026-09-03 — the **Change** button on the shared `LeadDetailModal`'s account-manager strip (so a physical session's door can reassign an arrival without leaving the roster) — all three open the shared `AssignLeadManagerModal` (single-select) which posts `assignee` (a staff uuid, or empty to unassign) to `LeadsController@assign` (route `manage.leads.assign`, `AssignRequest`); `LeadRepository::assign()` writes it. In the modal the component runs in its `host="modal"` mode (the same in-place Inertia transport as `LeadFormModal`: `preserveState` + `keepOpenDuringVisit()`, so the host page's roster refreshes underneath while the modal stays open, then `@saved` re-pulls `?section=lead`); its options are `quick()`'s `assignable_admins` (the same `assignableAdmins()` pool, sent only to a `manage-leads` holder). Assignment is **group-scoped**: a group actor may only reassign leads their own group generated, and only to an in-group (or platform) assignee — mirrored by the `can_assign` flag `transform()` emits alongside `assigned_admin` (`{uuid,name}|null`). The assignable pool is the shared `ResolvesAssignableManagers` (active manage-role staff in the actor's group). This same `assigned_admin_id` is the lead-ownership key `LeadVisibility` uses to scope who can see the lead. (Before 2026-07-18 account managers were a many-to-many on each membership subscription; that pivot was collapsed into this single lead-level value.)
- **Convert to member:** a lead's detail page can enrol the account into a membership tier (see the [Members](/docs/modules_handbook/manage/membership/members/readMe.md) module); lead status is left unchanged.
- **Show page (`Show.vue`) tabs** surface the lead's whole footprint via `ShowTabs`, in six groups — **Intelligence**, **Sales**, **Channel**, **Property Portal**, **Activity**, **Discussion** (plus **Physical Events**, which appears only when the lead has one). Three of them nest their own strip on their own query param: Sales **`?stab=`**, Channel **`?ctab=`**, Property Portal **`?ptab=`** — **each nested strip MUST be given a unique `param-name`**, or it silently fights the outer `?tab=` over the same key. **Intelligence** is the identity/enrichment report (`AccountTab`), then an **Owner archive** section (`OwnerArchiveSection` — what the separate owner archive holds against this person's phone/name, with a confidence band; gated on `view-owner-listing`, loaded lazily and audited on every read, see the [Owner Listing handbook](/docs/modules_handbook/manage/owner-listing/readMe.md)), followed by an **Attribution** section (`AttributionTab`, which also lists the lead's **Messenger captures**, fed by the `messengerCaptures` prop) — it is one tab, not two, because attribution is context you read next to the person rather than a place you navigate to.
- **Intelligence has two sub-tabs (`?itab=`, added 2026-09-18): Profile | Insight.** *Profile* is what that tab always was — the identity / enrichment report plus Attribution — so it stays first and stays the default. *Insight* is **every channel's reading of this person in one place**: the same panels `Channel → <channel> → Insights` mounts, against the same endpoints, driven by one shared list ([`composables/useLeadInsightTabs.js`](/resources/js/composables/useLeadInsightTabs.js)) so a sixth channel or a renamed label can never appear on one surface and not the other. **Overall comes FIRST** — the master reading, whose input is all the other readings (see [Channel Insights · Overall](/docs/modules_handbook/shared/channel-insights/overall.md)) — and **Sales and Property Portal's readings come last** (founder said yes to adding them): the channels are what this person SAID, those two are what they DID — and following a record either one cites LEAVES the tab for the strip that holds it (the host wires `@open`, since only it owns those refs). The AE suite, which has no Property Portal tab, gets no portal reading. Founder's words: *"at intelligence tab, introduce a tab called Insight … which is duplicate version of all the insight tabs at all channels"* — reading a person means reading every channel they used, and the channel strip makes that six clicks through six tabs that also carry the records. [`Partials/Tabs/AllInsightsTab.vue`](/resources/js/Pages/Manage/Leads/Partials/Tabs/AllInsightsTab.vue) draws the quiet `ChannelSubTabs` control over a `hideStrip` `ShowTabs` on **`?ictab=`**, so only the open channel is mounted (each panel reads its stored reading on mount — six would be six requests) and a copied link lands on the same channel's reading. A channel the role cannot open is never offered (`view-whatsapp` / `view-zoom` / `view-calls` / `view-f2f`), and with none of them the tab says so. The read-only `LeadDetailModal` does NOT mount this strip — like Sales Insights, a reading with its own paid **Analyse** button does not belong in a read-only modal.
- **⚠️ The tab tree is DEFINED ONCE, in [`composables/useLeadTabs.js`](/resources/js/composables/useLeadTabs.js)** — every label, order, permission gate and count badge, plus the legacy-key map. **Two** surfaces render a lead and must show the same thing: this page (fed by Inertia props from `show()`) and the shared read-only **`LeadDetailModal`** (fed by the JSON of `GET /manage/leads/{uuid}/quick`). They used to each declare their own list, and **drifted** — the modal was left on the flat, pre-regrouping set (Account / Membership / Attribution / Pipeline / Appointments side by side) long after the page had regrouped them, and never gained Property Match or Rental Estimate at all, even though `quick()` was already sending both. Each host now passes the composable one `get(key, fallback)` reader over whatever payload it holds; **that reader is the only difference between them.** Locked by [useLeadTabs.test.js](/resources/js/composables/useLeadTabs.test.js), which fails if either file is missing a `#tab-{key}` body for any key in the tree, or reintroduces a hand-rolled list.
- **The "Sales" tab groups everything about closing this person** (`?stab=`): **Insights** (first in the strip — the AI reading of every other tab below it; see [Sales Insights](/docs/modules_handbook/manage/leads/sales-insights.md)), **Pipeline** (per-project Caller→Closer→Follow-Up engagements — see [Engagements & Bookings](/docs/modules_handbook/manage/engagement/readMe.md)), **Membership**, **Appointments**, **Property Match**, **Rental Estimate**, and **CTA** (`CtaTab`, the lead's **WhatsApp CTA touches** — the wa.me links they responded to, fed by `whatsappCtas`; see [WhatsApp CTA Links](/docs/modules_handbook/manage/messages/whatsapp/cta_link.md), and previously a Channel sub-tab).
- **The "Property Portal" tab groups everything this person did inside the member portal** (2026-07-19 — it was called **"Property"**, and **Concierge** was a separate top-level tab until it was folded in). It nests its own `ShowTabs` on **`?ptab=`**, five sub-tabs in order: **Wealth Planning**, **Property Analyses**, **AI Conversations**, **AI Debates**, **Concierge** (the property-concierge requests). The AI pair is the **read-only history of the member's portal AI usage** — **AI Conversations** (the full chat thread) and **AI Debates** (the whole panel board + verdict); both are fed as plain props from `show()` (eager-loaded, no N+1) and rendered with the same safe markdown renderer as the portal, and admins only **view** (no streaming / no writes). See [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) + [AI Debate](/docs/modules_handbook/main/ai-debate/readMe.md).
  - **Activity is a TOP-LEVEL tab, sitting beside this group rather than inside it** (the FULL activity trail — everything that happened to this lead, not only what the lead did themselves; see [ActivityLogger](/docs/modules_handbook/shared/activity-log/readMe.md)). It was briefly a sixth sub-tab here, with the parent badge written to exclude it: the trail **grows forever** (`activityTotal` is the true server-side total, not the capped slice the tab renders), so folding it into the group badge would swamp every other sub-count and turn the number into noise. A sub-tab that has to be excluded from its own group's badge is not in the right group — so the Property Portal badge now simply sums its five children, and Activity shows its own real total on its own tab.
    - **Summary cards + category filter + full-history search.** Above the timeline: last-active / total-actions / projects-viewed / lessons-completed cards (`activitySummary`, computed over the WHOLE trail, not the capped slice) and a category-chip strip that doubles as the primary filter. A search box, a date range and a type multi-select (behind "More filters") round it out, plus a "Load more" that appends rather than paging. Every filter/search/date/load-more interaction round-trips to `GET {id}/activity` ([`LeadActivityController`](/app/Http/Controllers/Manage/Leads/LeadActivityController.php)) — client-side filtering over the capped slice is explicitly forbidden (it would silently filter a slice and present it as the whole trail). The read-only `LeadDetailModal` gets the summary cards only, no filter bar, no second fetch path.
  - **Outbound deep-links must carry both params.** Anything linking into a sub-tab needs `?tab=portal&ptab=<key>` — sending the sub-key as `?tab=` makes `ShowTabs` fall back to the first tab and lands the user on *Intelligence* (the legacy map below rescues the handful of keys it knows, not every key). Current callers: `LeadsController::analysis()`'s `backUrl` (`ptab=analyses`), `Portal/WealthPlans/Index.vue` (`ptab=wealth`), `Portal/Conversations/Index.vue` (`ptab=conversations`), and `Portal/Concierge/Index.vue` + `Show.vue` (`ptab=concierge`).
- **The "Channel" tab groups every way we have talked to this person** (2026-07-17). It nests its own `ShowTabs` on **`?ctab=`**, in order: **WhatsApp** (the read-only threads, below), **Zoom** (`ZoomTab`), **Phone Call** (`PhoneCallTab`, the phone-call artifacts — see [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md)), **AI Caller** (`Channel/AiCallerTab.vue`, 2026-08-24 — the Retell voice agent's calls to this lead with transcript + the WhatsApp follow-up's delivery ladder; fed by the eager `aiVoiceCalls` prop from the shared `Src\VoiceAgent\Support\AiCallPresenter`, gated `view-calls`, same rows in `show()` and `quick()` — see [voice-agent](/docs/modules_handbook/shared/voice-agent/readMe.md)), and **Showroom** (`ShowroomF2fTab`, the face-to-face recordings). WhatsApp and AI Caller are **permission-gated** (`view-whatsapp` / `view-calls`) and are simply absent — not empty — without it. The parent badge sums the sub-counts, mirroring the Property Portal tab.
  - **Messenger left this strip on 2026-09-17** (owner's request). `Channel/MessengerTab.vue`, the `messengerThreads` / `messengerThreadCount` props and `Src\Messenger\Support\LeadConversationPresenter` (which existed only for that tab) are gone with it. The Messenger MODULE is untouched — its inbox is on Manage → Messages — and a lead's Messenger **captures** still reach the **Attribution** tab through `messengerCaptures`. See [Meta Messenger](/docs/modules_handbook/manage/messages/messenger/readMe.md).
  - **Five channels now carry an AI reading of themselves, as a sub-tab beside the record** — WhatsApp `?vtab=`, Zoom `?ztab=insights`, Phone Call `?rtab=`, AI Caller `?atab=`, Showroom `?ftab=`. The record is always the first sub-tab and the default; each channel declares its own param in `useLeadTabs` so it is dropped when the reader moves to a channel without that level. One shared panel, one reading per lead per channel, generated only on an explicit click — see [Channel Insights](/docs/modules_handbook/shared/channel-insights/readMe.md) and its per-channel files ([AI Caller](/docs/modules_handbook/shared/channel-insights/ai-caller.md), [Showroom](/docs/modules_handbook/shared/channel-insights/showroom.md), [Phone Call](/docs/modules_handbook/shared/channel-insights/phone-call.md), [Zoom](/docs/modules_handbook/shared/channel-insights/zoom.md)). The Showroom reading is gated `view-f2f` and the AI Caller one `view-calls`, in the tab as well as in the endpoint.
- **Property Portal opens on Insights (2026-09-17).** The strip's FIRST sub-tab is an AI reading of every OTHER portal sub-tab — the developments they opened and the layouts they priced, the analyses they ran with their own price, the plan they entered (or abandoned), what they typed at the AI, what they watched — and it is the strip's default, because ten record tabs are a slow way to learn what someone is shopping for. Records, not a conversation: it cites `pi:` / `pa:` / `aim:` ids, each citation switches the strip to that record's tab, and every line it reads declares whether it is `behaviour`, `entered` or `typed`. ⚠️ It carries the FPA's credit-bureau figures into the prompt by the owner's decision. See [Portal Insights](/docs/modules_handbook/manage/leads/portal-insights.md).
- **Channel > WhatsApp — READ-ONLY threads (2026-07-17; Messenger's twin removed 2026-09-17).** It shows this person's conversations (a mini list on the left when they have more than one, the thread on the right) with an **"Open in Inbox"** link — **there is no composer here on purpose**: replying, the 24-hour window, templates and AI all stay in the module's own inbox, so this surface has no writes at all (no Repository, no Form Request). Fed by **`Src\Whatsapp\Support\LeadConversationPresenter::forLead()`** as an **`Inertia::optional`** prop (`whatsappThreads`) — the closure does **not** run on a normal page load, only on the partial reload the lazy-mounted sub-tab fires on open, so a lead page that never opens the tab pays nothing. Messages render through the shared **`resources/js/Components/Conversation/`** components, the same shape (`MessagePresenter`) the inbox uses.
  - **⚠️ The partial reload re-runs the WHOLE `show()`** (~11 queries) — Inertia only skips the *other* optional props, and the rest are eager arrays. So the tabs deliberately have **no poll** (unlike the inbox's 6s one): loading is on mount plus a manual Refresh button. Making `show()`'s heavy props optional is the real fix, and is not done yet.
  - **Two security controls live in this presenter, and neither is optional:** (1) **only `CHAT_INDIVIDUAL`** — a WhatsApp **group** thread is shared by many people and belongs to no single lead, and **`LeadVisibility` does NOT exclude it** (the group conversation still resolves through `contact.user.lead`, so its check passes), making the explicit `chat_type` filter the *only* thing preventing a whole group's chat from being shown on one member's lead page; (2) the lead↔contact link is **`contact.user_id`, matched STRICTLY** — never a tolerant phone match. Linking is [ContactLinker](/docs/modules_handbook/manage/messages/whatsapp/group.md)'s job (it verifies canonical E.164 first); a loose match here would hang a stranger's entire history off this lead. If a thread is missing, fix the **link**, not the query. Sandbox channels are excluded too. Locked by [LeadChannelTabTest](/tests/Feature/Lead/LeadChannelTabTest.php) — whose group-exclusion case is mutation-verified (removing the filter makes it fail).
  - **Channel > WhatsApp has two sub-tabs (`?vtab=`): Inbox and Insights (2026-09-17).** **Inbox** is the thread above, unchanged, and stays the default — it is the record. **Insights** is the AI reading of the WHOLE thread (every message, not the 30 the Inbox renders; a thread too long for one prompt is read in consecutive parts, never trimmed); ONE reading per lead of every number merged in time order, lines tagged by number, stored in `lead_channel_insights` — the number chips FILTER that reading, never analyse a line again; laid out brief → do now + reply → still owed → seven-area readiness → profile / watch-outs, every cited item opening in context: where this person stands, what was promised, what is at risk, and the message to send next. It is a **third** tab level, so its control is the quieter `Channel/ChannelSubTabs.vue` rather than a third row of pills (GUIDELINES §15), with `ShowTabs` still owning lazy-mount + URL sync underneath via `hideStrip`. Reading the panel costs nothing (a JSON `GET`, no provider call); only an explicit **Analyse** click spends a request, and an unchanged thread is never paid for twice. The read-only lead modal gets no sub-tabs (no `leadUuid`), so it stays exactly as it was. Full story: [Channel Insights](/docs/modules_handbook/shared/channel-insights/readMe.md).
  - **Legacy deep-links are seeded, not just rewritten.** The regroup swallowed former **top-level** tab keys — `account` / `attribution` → **Intelligence**, `membership` / `pipeline` / `appointments` / `cta` → **Sales**, `calls` / `zoom` → **Channel**, `property` / `concierge` → **Property Portal** — and `ShowTabs` **silently falls back to the first tab** on a key it does not recognise, so an old `?tab=calls` bookmark would land on *Intelligence* with no error. One **`LEGACY_TAB_MAP`** (in `useLeadTabs.js`) maps each old key to `[tab, subParam?, subKey?]`, and `remapLeadTabs()` applies it; `Show.vue` reads the URL's `?tab=` through it and seeds **both** strips via `v-model`. Seeding is what does the work: **`ShowTabs` resolves its initial tab from Inertia's `page.url`, which `history.replaceState` does NOT update** — so rewriting the address bar alone is inert. The `replaceState` that follows is purely cosmetic (so the URL copied from the page is the current one). Two details: legacy **`?tab=property` seeds an empty sub-key on purpose** — it already carried its own `?ptab=`, so the inner strip resolves that param itself — and an explicitly requested sub-key always **wins** over the one the map would supply. The **modal runs the same map** over the `{ tab, stab, ctab, ptab }` an opener passes to `openLead()`, so a caller still asking for `{ tab: 'concierge' }` lands where it meant to. Seeding a sub-key that does not exist for this lead is safe: `ShowTabs` watches its own tab list and falls back to the first entry when the active key disappears.
- **⚠️ ACTION ITEMS IS ITS OWN TAB — THE FIRST ONE (2026-09-19, hours after the bullet below).** Founder: *"make it
  a separate tab and not part of discussion. Make action item the first tab."* Everything below about the panel, the
  AI draft, topics and origin still holds; only WHERE it lives changed — so where that bullet says "the Discussion
  tab's main column", read "the Action Items tab".
    - `useLeadTabs` → `actionTab`, key **`actions`**, first in BOTH trees (the AE suite's too). With `activeTab`
      starting empty, `ShowTabs` opens the first tab — so **a lead now opens on its work queue**, on the page and in
      the `LeadDetailModal`. The badge counts what is still OPEN (a finished item is not waiting behind the tab) and
      the tab goes RED (`alert`, the same `ShowTabs` state WhatsApp uses) when any open item is overdue.
    - Body: [`Partials/Tabs/ActionItemsTab.vue`](/resources/js/Pages/Manage/Leads/Partials/Tabs/ActionItemsTab.vue),
      a thin wrapper round `ActionItemsPanel`, mounted as `#tab-actions` in `Show.vue` AND `LeadDetailModal.vue`
      (`useLeadTabs.test.js` fails if either lacks a body for a key in the tree). ⚠️ **It rides the DISCUSSION
      payload** (`discussion.action_items` / `admins` / `action_options`) — deliberately: that is the section the
      modal already re-pulls after an axios write (`refreshDiscussion`), so the move needed no new prop, endpoint or
      refresh path. Like Discussion, it overrides the modal's `leadReadonly` in `write-mode="modal"`.
    - `DiscussionTab` is discussion only again, full width. Zoom → Action Plans → Feedback links to `?tab=actions`
      (rating a step happens on the item), and the Leads list's Action hover says "Action Items tab".
- **ACTION ITEMS are the Discussion tab's MAIN column, with ✨ Improve with AI (2026-09-19).** Founder: *"I can
  simply type, then click the AI logo to improve grammar and reproduce a world-class action item (preferably point
  form) … tag a sales team member, assign due date and priority (first draft decided by AI), assign type … each
  item shows if it is created by AI or human … sales team closes the item … make the section bigger."* It used to be
  a 384px sticky rail beside a wide comment feed — the right shape for one-line to-dos, the wrong one for items that
  carry points, a type, a priority, a due date and an owner. Now the panel is `xl:flex-1` and the discussion is the
  26rem column (`order` does the swap, so the DOM — and everything keyed to it — did not move).
    - **TYPE is a new axis: `lead_action_items.topic`** (`LeadActionItem::TOPIC_*` / `TOPICS`). `action_type` says HOW
      a step is done (call, WhatsApp, send information…); it could not say what the step is ABOUT — of the **774**
      items the conversation analyses had produced (`zoom_meeting_action_plans.items`), **593 (77%) sat in its
      "Other"**, because "ask the banker to confirm the loan margin in writing" is not a channel. The ten topics are
      what those 774 were actually about, in the order a deal moves: Loan & Eligibility · Property Proposal ·
      Viewing & Meeting · Booking & Paperwork · Decision & Objections · Learning & Membership · After-sale & Rental
      · Referral & Relationship · Internal · Other. `action_type` stays, shown as "via …".
    - **EVERY item gets a topic, without every channel's prompt learning the taxonomy:**
      `LeadActionItemRepository::create()` reads one from the wording
      ([`ActionItemTopic::guess()`](/src/Lead/Support/ActionItemTopic.php) — ordered patterns, most specific first,
      so "loan margin for the Type A unit" is a LOAN step: the loan is what blocks it) whenever none is given. That
      is the hook for the founder's plan that *every channel insight's action items land here* — an insight only has
      to call `create()`. **Built 2026-09-19:** every reading now proposes items that land here once a colleague
      approves them on the Insight panel — see
      [Channel Insights → proposed action items](/docs/modules_handbook/shared/channel-insights/action-proposals.md). 81% of the 774 matched a topic (their `action_type` had placed 23% anywhere but Other).
      `leads:classify-action-items` backfilled the 26 existing rows (own repository method `backfillTopics()`: not
      an edit, so it stamps no `updated_by` and takes no journey-task lock).
    - **✨ Improve with AI** — [`ActionItemDrafter`](/src/Lead/Services/ActionItemDrafter.php) on `AiClient`, prompt
      key `lead_action_item_draft` (body in `resources/prompts/`), `POST manage/leads/{id}/action-items/draft`
      (JSON — a mid-form Inertia visit would reload the page out from under what is being typed). A rough note
      ("kx call him fri re loan margin type A") comes back as an action line + 2–5 points, in the note's own
      language, with a DRAFT topic, type, priority, due date and a one-line reason. **It saves nothing.** The model
      is an EDITOR, not an author: it may not add a name, figure or date the note did not contain; every code it
      returns is checked against the model's constant tables and a past or malformed date falls back to the
      priority's default (high +1d, medium +3d, low +7d, never a Sunday). ~6–11s, ~US$0.008 per draft on the
      default provider. ⚠️ `max_tokens` is 4000 for a ~200-token answer on purpose: on a reasoning model the THINKING
      counts against the cap, and the first live run spent 893 of 900 tokens thinking and returned half a JSON
      object. ⚠️ A new prompt key is invisible under the deployed config cache until `php artisan config:cache`.
    - **The AI drafts, the colleague decides — and the row can tell them apart.** The form posts what the AI proposed
      (`ai_priority` / `ai_due_on`) BESIDE what was kept; `ActionItemsController::classification()` stores
      `suggested_priority` always, and `priority_source` / `scheduled_source` = AI only when the kept value equals
      the draft. The AI never overrides a field the colleague filled before asking; **Undo** restores their words and
      clears only fields still holding the AI's value. A field the AI filled wears a ✨ until a human changes it.
      ⚠️ `classification()` returns ONLY keys the request sent: PHP is live before the build, and an old bundle
      editing an item's text must not wipe a priority somebody set. An explicit empty `due_on` DOES clear it — "no due
      date" is a choice.
    - **Origin** — `LeadActionItem::origin()`: **AI** (`source_action_plan_id` set — a conversation analysis wrote
      it), **AI-assisted** (`is_ai_assisted` — a colleague's words, tidied), **Human**. Shown as a badge on every row.
    - **The panel reads as a work queue** ([`ActionItemsPanel`](/resources/js/Pages/Manage/Leads/Partials/Tabs/ActionItemsPanel.vue)):
      a summary strip (open · overdue · mine · done · % closed), views (Open / Mine / Overdue / Done / All) plus a
      Type filter offering only the topics this lead uses, open items first → most urgent → soonest due, one divided
      list rather than a wall of cards. A row: the action as a headline, its points (`parseActionBody` — line 1 is
      the title, "- " lines are points, anything else is kept as `rest`, never dropped), then topic · priority · due
      (red "Overdue 2d" once late, `is_overdue` is the SERVER's flag) · via · origin · owners ("Nobody assigned" in
      amber). Writing and editing share ONE form ([`ActionItemEditor`](/resources/js/Pages/Manage/Leads/Partials/Tabs/ActionItemEditor.vue))
      — they used to be the same markup pasted twice. Names and colours all come from `LeadActionItem::formOptions()`
      in the discussion payload (`action_options`); [`utils/actionItems.js`](/resources/js/utils/actionItems.js) only
      turns a colour name into classes.
    - **The hook for channel insights (2026-09-19).** A sibling build adds AI-PROPOSED steps to every channel's
      Insight tab, in its own `lead_action_proposals` table with its own approval queue — deliberately NOT a
      "pending" status on `lead_action_items`: every reader of that table (this tab, the list's Action column and
      its counts / sort / filter, `/manage/action-items`, the dashboard checklist, the journey sync) assumes a row
      is REAL work, and one missed exclusion would leak an unapproved AI guess into a manager's count. On approve
      it calls `LeadActionItemRepository::create()` with `source_channel` + `source_proposal_id` (migration
      `2026_09_19_210000`), which makes `origin()` = AI (`isAiGenerated()`), puts "AI · whatsapp insight" on the
      row, and lets the Insight tab say "approved → task". Such an item is closable by its ASSIGNEE only, like a
      plan step — except when approved with nobody on it, where it falls back to the shared-checklist rule,
      because a task nobody may close stays open forever.
    - **Assignees are resolved and alerted in ONE place:**
      [`Src\Lead\Services\ActionItemAssignees`](/src/Lead/Services/ActionItemAssignees.php) — `resolveIds(uuids)`
      (manage-role holders only; a bad uuid is dropped, not refused) and `notify(actor, lead, item, userIds)`
      (Notify's addressed path, event `leads.action_item_assigned`, never the actor, pass only NEWLY added ids on
      an edit). They were private methods on `ActionItemsController`; both fail QUIETLY by design, which is
      exactly why a second copy in a second controller would be dangerous. The alert's link opens `?tab=actions`.
    - Permissions are unchanged and still the server's (`can_toggle` / `can_edit` / `can_delete`): anyone who can see
      the lead closes a manual item, only an assignee closes an AI-generated one, the author edits, the author or a
      super-admin deletes.
- **The "Discussion" tab is the internal admin workspace for the lead** — never surfaced to the portal user. Left: the **general comment feed** + **topic threads** (`DiscussionTab.vue`; **any admin may edit or delete any comment/topic** — the owner-only guards were dropped 2026-08-11: it is a shared team workspace, and authorship still shows via `created_by` / the "You" badge). Lead-shaped rosters deep-link INTO it through ONE shared component, [`Components/LeadDiscussionButton.vue`](/resources/js/Components/LeadDiscussionButton.vue) (2026-08-12 — the engagement tables, the Property Match view and the VSL funnel roster all mount it, and it carries a violet **comment-count badge** fed by the shared `Concerns\BuildsDiscussionCounts` trait, one grouped query per page keyed `(lead, topic-title)`). It calls `openLeadDiscussion(uuid, title)` (`useLeadModal.js`), which **find-or-creates a topic named for the source** — the project's name, the literal `Property Match`, or a VSL funnel's **linked project** (`event_funnels.project_id`, else the funnel's own name — so a marketing remark and a sales remark about one deal share ONE thread) — via `POST …/discussion/topics/ensure` (idempotent by (lead, title); a deleted topic stays deleted, a fresh one is minted), then opens the modal landed on that thread (`requestedTabs.topic` → `DiscussionTab`'s `focusTopicUuid`). The ensure runs BEFORE `openLead` on purpose: the modal fetches its payload on open, and a topic created after that fetch wouldn't be in it. Right: a **sticky Action Items rail** (`ActionItemsPanel.vue`, 2026-08-03) — an admin writes what needs doing, attaches **images / videos** (validated in the Form Request per the [Media](/docs/modules_handbook/shared/media/readMe.md) handbook, stored via `MediaService` under collection `action-item`, rendered inline off short-lived signed URLs) and assigns it to **one or more admins**. Each **newly** assigned admin is alerted on their own phone through the shared [Notify](/docs/modules_handbook/shared/notify/readMe.md) service's **addressed path** (`Notifier::sendToUsers`, event `leads.action_item_assigned` — personal destinations only, never shared groups; the actor is never self-notified; re-saving an item does not re-buzz existing assignees). ⚠️ Only holders of a **manage role** can be assigned at all (`resolveAssigneeIds`), so a role-less account is dropped before the alert is even considered. Pinned by `tests/Feature/Leads/ActionItemNotifyTest.php` — every rule here fails SILENTLY (a colleague simply never hears about their task), which is why it is tested rather than trusted. Anyone who can view the lead may tick an item **done** / reopen it (it is a shared checklist); edit stays with the author, delete with the author or a super-admin. Both columns ride the one `discussion` payload (`comments` / `topics` / `action_items` / `admins` — the assignee picker options), so the modal's `?section=discussion` refresh keeps the panel live too — and since 2026-08-12 that refresh ALSO fires a bracketed `router.reload()` (`keepOpenDuringVisit`, `preserveState`) so the HOST table's discussion badges pick up the comment just posted or deleted; the axios path cannot patch that number itself, because it lives in the host's props, not the modal's — and deleting an item hard-deletes its attachment files first (`MediaService::delete`, outside the transaction) so no bucket object is stranded.

### Deleting ONE lead — a hard delete, now guarded (2026-08-06)

`leads` has **no soft delete**, and the `deleting` cascade (`Lead::booted()`) **force-deletes** the person's subscriptions, comments, topics, engagements and bookings. There is no undo, and until this date there was no guard and no audit trail either.

It had already cost money. An **ACTIVE MYR 100 Stripe payment** (`purchase_histories` #273 → lead 12801, `pi_3TqZbvEWKNIsKNuK0HBAoy0V`, paid 7 Jul 2026) points at a lead that no longer exists — and because every revenue query joins **through** the lead, that payment is invisible on every screen, *including to a super-admin*. Twelve rows across five tables are orphaned the same way, and nothing recorded who pressed the button.

Three changes, each pinned (and mutation-verified) by [LeadDeletionGuardTest](/tests/Feature/Lead/LeadDeletionGuardTest.php):

- **`destroy()` REFUSES rather than warns.** A lead carrying a `purchase_histories`, `member_subscriptions`, `bookings` or `engagements` row cannot be deleted, and the flash names what is in the way. ⚠️ The counts are raw and therefore **include soft-deleted rows** — a refuse-gate must fail closed, and a binned payment orphans exactly as a live one does. Side effect worth knowing: any lead that has ever entered the sales pipeline is now undeletable from this screen.
- **Every deletion is logged** — actor, lead uuid, account email — to the **application log**, not the activity trail. The trail is scoped to a lead, so it would be destroyed by the very action it is meant to record.
- **The cascade no longer leaks.** `$lead->engagements()->get()` silently skipped **already-trashed** engagements (Engagement is soft-deletable), so they and their bookings survived pointing at a deleted lead — which is precisely how engagement #207 / booking #195 became orphans. Now `withTrashed()`. At the time of the fix, **22 trashed engagements and 32 trashed bookings** were sitting on live leads, each an orphan-in-waiting.

**Finding what already leaked:** `php artisan leads:orphans` ([ReportOrphanedLeadRecords](/app/Console/Commands/ReportOrphanedLeadRecords.php)) reports every row whose `lead_id` resolves to nothing, money tables first. Read-only by design — repairing a payment means identifying the real buyer at the gateway, and guessing would file money under the wrong person. ⚠️ It scopes the table scan to the **current database**: `Schema::getTableListing()` returns schema-qualified names for *every* database the connection can see (1,608 on a dev machine), so stripping the prefix without filtering both triple-counts and reads schemas that are none of our business.

> **Why not just soft-delete `Lead`?** Investigated 2026-08-06 and deliberately rejected. Laravel fires `deleting` on a **soft** delete too, so the cascade above would still force-delete the children — you would restore an empty shell. Worse, `EnsureUserIsMainUser` treats a hidden lead as *absent* and calls `ensureLeadForPortal`, whose `firstOrCreate` is equally blind to the trashed row: it would try to INSERT a second lead and hit `leads_user_id_unique` — a permanent 500 for that customer, on every page, unfixable from the admin screen. The same `firstOrCreate` pattern guards six lead-creation sites including the Stripe webhook and passwordless sign-in. Separately, Malaysian law grants no right to erasure (the 2024 amendment added portability, a DPO duty and breach notification, not deletion); s.38 requires *ceasing to process*, for which the existing `User::STATUS_MERGED` shape — retire the account, free its unique keys — is the working precedent.

## Danger zone — "Delete All Leads" (TEMPORARY · **LOCAL DEV ONLY since 2026-07-15**)

> **Local dev only.** The one-off data-wash tool was switched off in production on 2026-07-15 — it also wiped staff portal leads, and its job was done — then **re-scoped to `APP_ENV=local`** so a developer can still reset their own machine's data. **Off local it does not exist:** the route in `routes/web.php` is registered inside `if (app()->environment('local'))` (the endpoint 405s — the `purge-all` URI only matches the GET/PUT/DELETE `{id}` routes), `purgeAll()` re-checks with `abort_unless(app()->environment('local'), 404)`, and the button is hidden because the controller ships `purgeEnabled => app()->environment('local')` as an Inertia prop to `Leads/Index.vue`. All three read the **same** env, so there is no flag to flip and nothing to forget: a deployed environment can never show or run it. Locked by [PurgeAllLeadsTest](/tests/Feature/Lead/PurgeAllLeadsTest.php) (the suite runs as `testing`, so it asserts the endpoint is unroutable); the purge **logic** (`LeadRepository::purgeAll` + `PurgeAllLeadsJob`) is kept and still tested there too.

A **super-admin-only** button on the Leads index that permanently wipes **every** lead, everything hanging off those leads, and the **customer `users` accounts** behind them — originally the "refresh the client's production data" reset, now a local dev convenience. It is temporary: **delete it once it is no longer wanted** (files listed at the end of this section).

**Gates (four, independent):** the environment (`local` only — route registration, controller, and UI prop) · the route's `role:super-admin` middleware · `abort_unless($request->user()->isSuperAdmin(), 403)` in the controller · a typed confirmation phrase (`DELETE ALL LEADS`) validated **server-side** by `PurgeAllRequest` (the modal disables the button, but the client is never the gate). The work is **queued** (`PurgeAllLeadsJob`, `$tries = 1`) because it walks ~50 tables and deletes the stored files.

### Which accounts are deleted — the one thing to get right
A `users` row is deleted **only** when it is pointed at by `leads.user_id` **and** it is neither of these:
- it has an **`admins` row — including a soft-deleted one** (an ex-admin's account must survive), nor
- it holds **any role other than `member` / `non-member`**.

Both signals are needed: `ContactLinker` auto-creates a CRM lead for *any* reachable inbound 1:1 WhatsApp contact, so an admin who messages the company number from their own phone ends up owning a `leads` row. Their **lead is purged; their account is not.** `leads.assigned_admin_id` is an *admin's* `users.id` and is never a deletion key — neither are `created_by` / `updated_by` / `deleted_by` (blame columns) or `admin_id` (which is `admins.id`, not `users.id`).

### How it works
`LeadRepository::purgeAll()` chunks by lead (200 at a time) and, per chunk: resolves every dependent id set **first** (nothing is findable once its parent is gone) → deletes the **stored files** → deletes the **rows** in one transaction.

- **It deliberately does NOT use the `Lead` / `User` deleting hooks.** Those cascade ~12 tables out of ~50, and since the schema carries **no foreign-key constraints** (GUIDELINES §7) the rest would be orphaned *silently*. Every table is deleted explicitly, children before parents. (The hooks are untouched — the single-lead `destroy()` still uses them.)
- **Raw `DB::table()` queries throughout**, so neither a soft-delete scope nor a **global scope** can hide a row: `CallRecording` has a `NotIgnoredScope`, so an Eloquent delete would silently skip every `is_ignored` recording. A soft delete would also just leave the customer's PII sitting in the table.
- **Files go through `MediaService::delete()`** — the only path that removes the **GCS object** as well as the `media` row. Storage I/O happens **outside** the transaction (a bucket delete cannot be rolled back) and a failure is logged without aborting the purge: a stranded object is recoverable, a half-purged database is not. Concierge photos live on the local `public` disk and are deleted directly.

### What is deleted
`leads` + the customer's `users` / `user_profiles` / `addresses` / role assignments, and: `lead_funnels`, `lead_enrichments`, `lead_ai_credits`, `lead_comments`, `lead_topics`, `ai_credentials` (lead-scoped), `member_subscriptions`, `purchase_histories`, `bookings`, `engagements`, `appointments`, `event_registrations`, `zoom_meetings`, `zoom_recordings`, `zoom_webinar_attendances`, `zoom_webinar_responses`, `call_recordings`, `f2f_recordings`, `agent_call_events`, `wealth_plans`, `wealth_whopay_reports`, `property_analyses`, `concierge_requests` (+ `_events`, `_photos`), `lms_lesson_progress`, `ai_conversations` (+ messages), `ai_debates` (+ responses), `ai_requests` (by `lead_id` **or** by a matching `subject` morph — the WhatsApp/recording pipelines log with `lead_id` NULL), `funnel_whatsapp_sends`, `whatsapp_cta_captures`, `messenger_captures`, the leads' `whatsapp_contacts` / `messenger_contacts` and their conversations, messages, attachments, consents and tag pivots, plus `flg_leads` (its `meta_leadgen_id` is UNIQUE — a survivor would silently block that Meta lead from ever being re-ingested).

### What survives
Admin accounts + `admins` rows + roles/permissions · WhatsApp & Messenger **channels**, templates, flows, broadcasts, tags, segments · funnels, events, webinars · memberships, courses, projects · **global** `ai_credentials` (`lead_id IS NULL`) and admin/system `ai_requests`. Three tables are **unlinked, not deleted** (admin-owned rows that merely reference a lead): `activities.lead_id` → NULL, `flg_owner_listing_rows.lead_id` → NULL, `whatsapp_group_participants.contact_id` → NULL (the group roster is channel infra and is re-upserted from the Bridge anyway). Group messages sent by a purged contact keep their row with `sender_contact_id` → NULL.

### Accepted side effects
- **Zoom is not called.** Meetings and registrants already created on Zoom stay there: a purged lead's join URL keeps working and Zoom may still send its own reminder emails. (The repo has no cancel-registrant call.)
- `zoom_recordings` is a metadata cache only — the recordings themselves live in the Zoom account.
- Queued jobs still holding a reference to a deleted lead/user will fail into `failed_jobs`. Run the purge in a quiet window.
- `LeadVisibility` / `group_id` are **deliberately not applied** — this is an all-leads wipe gated on super-admin. A group-scoped purge would leave inconsistent cross-group children and is a separate feature.

### To remove this feature
Delete `app/Jobs/Lead/PurgeAllLeadsJob.php`, `app/Http/Requests/Manage/Leads/PurgeAllRequest.php`, `tests/Feature/Lead/PurgeAllLeadsTest.php`, `LeadsController::purgeAll()`, the `manage.leads.purge-all` route, the purge block + "Delete All Leads" button in `Index.vue`, the purge methods on `LeadRepository`, and this section. (`ConfirmModal`'s `confirmDisabled` prop is generic — keep it.)

---

## Related files

**Backend — Models**
- [src/Lead/Lead.php](/src/Lead/Lead.php) — the person: `user_id` + `flg_lead_id` + `status`; `STATUSES`; `belongsTo(User)`, `hasMany(leadFunnels)`, `belongsToMany(funnels)`.
- [src/Lead/LeadFunnel.php](/src/Lead/LeadFunnel.php) — a registration: `lead_id` + `event_funnel_id` + `registered_at` + all attribution (utm_*, `fbclid`, **`fbp` / `fbc`**, campaign/adset/ad ids, placement, referrer, landing_url, ip, user_agent); `SOURCES` + source accessors; `belongsTo(Lead)`, `belongsTo(EventFunnel)`.
- [src/Lead/LeadActionItem.php](/src/Lead/LeadActionItem.php) — a Discussion-tab action item: `STATUS_OPEN` / `STATUS_DONE` + `STATUSES`; `assignees()` (belongsToMany users via `lead_action_item_assignees`), `media()` morph (collection `action-item`), `toShowArray()` (permission flags + signed attachment URLs).

**Backend — Linking (the unified shell)** — documented in [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md)
- [src/Lead/Services/LeadLinker.php](/src/Lead/Services/LeadLinker.php) + [src/Lead/Support/](/src/Lead/Support/) (`LinkRequest` / `LinkResult` / `LeadAttribution`) + [tests/Feature/Lead/LeadLinkerTest.php](/tests/Feature/Lead/LeadLinkerTest.php).

**Backend — Repositories**
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — the **identity gate**: `firstOrCreateForIdentity()` / `resolveUserByIdentity()` / `matchUserByPhone()` / `phoneOwnedByAnother()` / `backfillContact()`; `firstOrCreateForPhone()`/`ForEmail()` delegate to it; `create()` (create-only status), `firstOrCreateForUser()`, `assign()` / `unassign()`, the quality verdicts `setAgentVerdict()` / `setFakeVerdict()` / `setQuality()` (the one-word form, both pairs at once), and the **temporary** `purgeAll()` (see *Danger zone* above).
- [src/Common/Support/PhoneNumber.php](/src/Common/Support/PhoneNumber.php) — phone tolerance: `digits()` (storage, keeps trunk-0), `e164()` / `canonicalDigits()` / `candidates()` / `sameNumber()`. `Src\Whatsapp\Services\PhoneNormalizer` + `Src\Call\Support\LeadMatcher` build on it.
- [src/Lead/Repositories/LeadFunnelRepository.php](/src/Lead/Repositories/LeadFunnelRepository.php) — `attach($lead, $funnelId, $input)` — idempotent per `(lead, funnel)`, captures first-touch attribution (also exposed via `Src\Lead\Facades\LeadFunnelRepository`).

**Backend — Quality-state resolver + export**
- [src/Lead/Support/LeadQuality.php](/src/Lead/Support/LeadQuality.php) — the shared Agent/Fake
  resolver: `agentState()`/`fakeState()`/`agentSource()`/`fakeSource()`, `AGENT_STATES`/
  `FAKE_STATES` metadata, `agentLabel()`/`fakeLabel()` (the "Fake?" hedge), `agentStateSql()`/
  `fakeStateSql(string $table = 'leads')`, `agentEvidence()`/`fakeEvidence()`; the one-word
  reading `adminQuality()` / `quality()` + `QUALITY_STATES` (2026-09-08).
- [app/Exports/LeadsExport.php](/app/Exports/LeadsExport.php) — the Excel/CSV download; `Agent
  verdict`/`Fake verdict`/`Lead quality` resolved via `LeadQuality`.

**Backend — Controller**
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — search / index / **export** (⚠️ hands `LeadsExport` the BUILDER, never `->get()`, and appends a `leads.id` tiebreaker: the export is `FromQuery` so the writer chunks it — see the [Export handbook](/docs/modules_handbook/shared/export/readMe.md) for the 406 MB → 158 MB measurement behind that) / store / importPreview / import / show / showAnalysis / enrich / convert / update / assign / destroy / purgeAll (uses `UserRepository` for contact edits; `store` links through `LeadLinker`). Also `buildActivityRows()` (first-paint capped slice) + `buildActivitySummary()` (the Activity tab's summary cards, over the whole trail) on both `show()` and `quick()`.
- [app/Http/Controllers/Manage/Leads/LeadActivityController.php](/app/Http/Controllers/Manage/Leads/LeadActivityController.php) — `GET {id}/activity`: the Activity tab's filtered, paginated trail (category / type / search / date range), gated by the same `LeadVisibility` check as `show()` / `quick()`. Shares its row mapper with `LeadsController::buildActivityRows()` via [app/Http/Controllers/Concerns/BuildsActivityRows.php](/app/Http/Controllers/Concerns/BuildsActivityRows.php) — see the [ActivityLogger handbook](/docs/modules_handbook/shared/activity-log/readMe.md) for the shape.
- [app/Http/Controllers/Manage/Leads/ActionItemsController.php](/app/Http/Controllers/Manage/Leads/ActionItemsController.php) — store / update / destroy / done / reopen for the Discussion tab's Action Items rail (`manage.leads.action-items.*`): resolves assignee uuids server-side (manage roles only), stores attachments via `MediaService`, fires the addressed `leads.action_item_assigned` notify for **newly** added assignees, and hard-deletes attachment files before the row on destroy. Uses [src/Lead/Repositories/LeadActionItemRepository.php](/src/Lead/Repositories/LeadActionItemRepository.php) for every DB write.

**Backend — Middleware (app-wide, listed here because the Discussion tab is what exposed it)**
- [app/Http/Middleware/PreventRedirectMethodReplay.php](/app/Http/Middleware/PreventRedirectMethodReplay.php) — answers every PUT / PATCH / DELETE redirect with **303 See Other** instead of 301/302, so a browser following an axios write's redirect issues a GET rather than replaying the write against the page the admin is standing on. First entry in `app/Http/Kernel.php`'s global stack. 307/308 are left alone — those statuses exist to *say* "replay the method".

**Backend — Form Requests**
- [app/Http/Requests/Manage/Leads/StoreRequest.php](/app/Http/Requests/Manage/Leads/StoreRequest.php) — name / email / phone / source for the manual "New lead"; `withValidator()` previews through `LeadLinker::preview()` to refuse an email/phone that already belongs to a person or to staff (the same classifier the controller's write uses).
- [app/Http/Requests/Manage/Leads/UpdateRequest.php](/app/Http/Requests/Manage/Leads/UpdateRequest.php) — name / email / phone (status is not editable here).
- [app/Http/Requests/Manage/Leads/LeadQueryRequest.php](/app/Http/Requests/Manage/Leads/LeadQueryRequest.php) — search (via user) / status / source / funnel / date_from / date_to / **agent** / **fake** (Phase 2's `LeadQuality` resolver, whitelisted `whereRaw`).
- [app/Http/Requests/Manage/Leads/AssignRequest.php](/app/Http/Requests/Manage/Leads/AssignRequest.php) — set the lead's single account manager: one field `assignee` (a staff uuid, nullable = unassign) → `leads.assigned_admin_id`.
- [app/Http/Requests/Manage/Leads/ConvertRequest.php](/app/Http/Requests/Manage/Leads/ConvertRequest.php) — convert-to-member (membership + `price_paid` + `paid_at`).
- [app/Http/Requests/Manage/Leads/VerdictRequest.php](/app/Http/Requests/Manage/Leads/VerdictRequest.php) — the admin quality verdict: `kind` (agent|fake) + nullable `verdict` (0/1; null clears back to the AI guess).
- [app/Http/Requests/Manage/Leads/QualityRequest.php](/app/Http/Requests/Manage/Leads/QualityRequest.php) — the one-word quality: nullable `quality` (legit|agent|fake; null clears both verdicts).
- [app/Http/Requests/Manage/Leads/ImportRequest.php](/app/Http/Requests/Manage/Leads/ImportRequest.php) — the CSV/Excel upload (preview + apply).
- [app/Http/Requests/Manage/Leads/PurgeAllRequest.php](/app/Http/Requests/Manage/Leads/PurgeAllRequest.php) — **temporary**: the typed `DELETE ALL LEADS` confirmation.
- [app/Http/Requests/Manage/Leads/LeadActivityQueryRequest.php](/app/Http/Requests/Manage/Leads/LeadActivityQueryRequest.php) — the Activity tab's `category` / `type[]` / `q` / `date_from` / `date_to` filters (extends `ManageQueryRequest`, GUIDELINES §9/§14).
- (Sub-folders: `ZoomMeetings/StoreRequest.php`, `Discussion/StoreCommentRequest.php` + `StoreTopicRequest.php` + `UpdateCommentRequest.php` + `UpdateTopicRequest.php`, `ActionItems/StoreRequest.php` + `UpdateRequest.php` — the latter pair validates the image/video uploads: mimetypes + 50 MB, at most 6.)

**Backend — Jobs**
- [app/Jobs/Lead/EnrichLeadJob.php](/app/Jobs/Lead/EnrichLeadJob.php) — queued identity enrichment.
- [app/Jobs/Lead/PurgeAllLeadsJob.php](/app/Jobs/Lead/PurgeAllLeadsJob.php) — **temporary**: runs `LeadRepository::purgeAll()` off the request cycle.

**Tests**
- [tests/Feature/Lead/PurgeAllLeadsTest.php](/tests/Feature/Lead/PurgeAllLeadsTest.php) — **temporary**: proves the purge wipes the lead's whole footprint (rows + GCS objects) and never touches a staff / ex-staff account.
- [tests/Unit/Lead/LeadQualityTest.php](/tests/Unit/Lead/LeadQualityTest.php) — `LeadQuality`
  table-driven (every admin/AI combination) + PHP↔SQL agreement over a seeded DB matrix +
  `fakeLabel()`/`agentLabel()` + the `$table` qualifier override.
- [tests/Feature/Lead/LeadQualityFilterTest.php](/tests/Feature/Lead/LeadQualityFilterTest.php) —
  the Leads index filter/counts agreement, visibility scoping, export parity (filter honoured,
  `per_page` never honoured, resolved labels incl. the "Fake?" hedge), and the export's N+1 guard
  (query count stays flat as the export's lead count grows).

**Backend — Contact source (shared)**
- [src/People/User.php](/src/People/User.php) — the linked account (email, role).
- [src/People/UserProfile.php](/src/People/UserProfile.php) — `full_name`, `phone` (digit-normalizing mutator).
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — used to update the profile.

**Frontend (Vue)**
- [resources/js/Pages/Manage/Leads/Index.vue](/resources/js/Pages/Manage/Leads/Index.vue) — list, filters (incl. Agent / Fake lead), inline status, pagination; the `Quality` column (`#cell-quality`: the effective Legit / Agent / Fake chip + the admin's inline `<select>` → `POST {id}/quality`).
- [resources/js/Components/Leads/QualityTagMeta.js](/resources/js/Components/Leads/QualityTagMeta.js) —
  the single label/colour/icon source (mirrors `LeadQuality::AGENT_STATES`/`FAKE_STATES`);
  `qualityStates(insight)` (the `insight === null` default contract); `agentLabel()`/`fakeLabel()`
  (the "Fake?" hedge); `matchesQualityOption(insight, option)` (the roster triage-bucket
  definition, shared by both rosters' different filter idioms).
- [resources/js/Components/Leads/QualityTagChips.vue](/resources/js/Components/Leads/QualityTagChips.vue) —
  the shared display-only tag chips (Leads index, VSL roster, Registrations roster).
- [resources/js/Components/LeadQualityTags.vue](/resources/js/Components/LeadQualityTags.vue) —
  the Show page / `LeadDetailModal` identity-header tags **+ the admin verdict-setting dropdown**
  (the one write surface — the two chip components above are display-only); consumes
  `QualityTagMeta.js` for its label/colour source.
- [resources/js/composables/useLeadTabs.js](/resources/js/composables/useLeadTabs.js) — **the one definition of the tab tree** (outer strip + the Sales / Channel / Portal sub-strips, their counts and gates) plus `LEGACY_TAB_MAP` / `remapLeadTabs()`. Shared by `Show.vue` and `LeadDetailModal.vue` so the two can never drift apart again; locked by [useLeadTabs.test.js](/resources/js/composables/useLeadTabs.test.js).
- [app/Http/Controllers/Manage/Leads/LeadWhatsappInsightsController.php](/app/Http/Controllers/Manage/Leads/LeadWhatsappInsightsController.php) — Channel > WhatsApp > **Insights**: read the stored analysis (`GET`, never calls the provider) / generate one (`POST`). See [Channel Insights](/docs/modules_handbook/shared/channel-insights/readMe.md).
- [resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/) — `WhatsappTab.vue` hosts the Inbox | Insights sub-tabs; `WhatsappInboxPane.vue` is the original thread view, `ChannelInsightsPanel.vue` the analysis, `ChannelSubTabs.vue` the third-level control.
- [resources/js/Pages/Manage/Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) — detail page: identity header + `ShowTabs` (Intelligence / **Sales** / **Channel** / **Property Portal** / Activity / Discussion / (Physical Events)), the three grouped tabs nesting their own strip on `?stab=` / `?ctab=` / `?ptab=` and seeding legacy deep-links through `remapLeadTabs()`. Owns what the modal does not: Delete, the account-manager **Change** button, and the lazy `?callDetail` / `?f2fDetail` / `?zoomDetail` recording loads.
- [resources/js/Components/LeadDetailModal.vue](/resources/js/Components/LeadDetailModal.vue) — the same tree rendered **read-only** in a full-screen modal, mounted once in `ManageLayout` and opened from anywhere via `useLeadModal().openLead(uuid, { tab, stab, ctab, ptab })`. One `fetch` of `GET /manage/leads/{uuid}/quick` replaces the Inertia props; a `provide('leadReadonly', true)` tells every reused tab to hide its write controls and skip its own partial reloads (which would hit the **host** page). Every strip runs `:sync-url="false"` — the address bar belongs to whatever page the modal floats over. **Discussion is the one interactive tab**: it writes via axios (`write-mode="modal"`) and emits `refresh`, which re-pulls only `?section=discussion`. **"Open full page" is a plain `<a target="_blank" rel="noopener">`, not an Inertia `<Link>`** — a client-side visit has no meaning for a new tab — and it deliberately does **not** call `closeLead()`: the click never navigates this tab, so closing the modal would mean switching back to find it, the scroll position and any Discussion draft gone.
  - **Edit lives in the header too** (added 2026-08-06, `manage-leads` only). It opens the SAME `LeadFormModal` as the Leads index row action and the Show page, passed `host="modal"`. The save is an **ordinary Inertia visit kept in place** — three options, and each earns its keep:
    1. **`preserveState: true`** — the visit's whole point is that the host page's controller re-runs, so its table, counters and props come back fresh *on every screen that can open the modal, with no per-host wiring.* Without it Inertia stamps a new component key, the page component (and with it `ManageLayout` → this modal → the payload it fetched) **remounts**, and the modal is left open and **blank** — `activeUuid` never changed, so its `watch` never re-fires and it never reloads itself.
    2. **`keepOpenDuringVisit()`** ([useLeadModal](/resources/js/composables/useLeadModal.js)) — stands the close-on-navigate rule down for that one visit. Mostly belt-and-braces: Inertia does **not** fire `navigate` when the destination equals the current URL (`replace` wins), and `redirect()->back()` normally returns the same URL. It matters when `back()` resolves to the URL *without* `?lead=` (that param is added by `history.replaceState`, so a stale session `_previous.url` has no memory of it). Always release it from `onFinish`.
    3. **`@saved` → `?section=lead`** — the visit refreshed the HOST; the modal's own payload came from a separate `fetch` and cannot ride it, so it re-reads just the lead row and patches `data.lead` in place, keeping the open tab, the scroll position and any Discussion draft.
    - The **confirmed merge** deliberately does none of this: the server collapses the two accounts and redirects to the leads index, because the lead on screen may be the side that was absorbed — so the ordinary close-on-navigate rule is exactly right.
  - **⚠️ Do not "solve" the modal-survives-a-save problem by writing over axios.** The first build did, and dodging Inertia dodged everything Inertia carries: `flash()` was written to the session and then **consumed by the redirect axios silently follows**, so no confirmation ever appeared; and the host page's props were never re-read, so its table kept showing the old name until a manual browser refresh. The save worked and the screen said nothing. The fix is to keep the visit and teach the modal to survive it, not to leave the pipeline.
  - **⚠️ An axios write that redirects is a loaded gun — the safety now lives in middleware.** `back()` answers **302**, and a browser follows an XHR redirect *by itself*; per the fetch spec it rewrites the method to GET **only for a POST**, so a **PUT or DELETE is replayed verbatim** against `back()`'s target — which is the page the modal happens to be floating over. On **2026-08-12** that deleted a live funnel (94 leads, ads running): the modal's `DELETE /manage/leads/{id}/discussion/topics/{uuid}` redirected 302 to `/manage/events/funnels/{uuid}?tab=vsl-leads`, the browser replayed the DELETE there, and it matched `DELETE events/funnels/{id}`. The same shape reached `DELETE sales-projects/{id}` (the project Leads tab) and `DELETE leads/{id}` (the Lead Show page). Inertia's own middleware already performs the cure — downgrade 302 → **303 See Other**, the one status that MAKES a browser follow with GET — but it returns early for any request without the `X-Inertia` header, so it protected Inertia visits and left every axios write exposed. [`App\Http\Middleware\PreventRedirectMethodReplay`](/app/Http/Middleware/PreventRedirectMethodReplay.php) now applies that rewrite **globally and unconditionally** for PUT / PATCH / DELETE, first in the kernel stack so nothing can undo it, and [`tests/Feature/Security/RedirectMethodReplayTest.php`](/tests/Feature/Security/RedirectMethodReplayTest.php) pins it. **The guard has to be a status assertion**: a 302 on a DELETE looks entirely ordinary from the server, the damage happens in the browser afterwards, and nothing in a request log says so — which is also why `assertRedirect()`, not `assertStatus(302)`, is the assertion to reach for on a write.
  - **Both headers badge the proof state of each contact key** ([Components/VerifiedBadge.vue](/resources/js/Components/VerifiedBadge.vue), fed by `email_verified_at` / `phone_verified_at` on the lead row `transform()` already emitted for the edit form). Sign-in mails/texts the code **to** the key, so "Not verified" is not decoration — it is the reason that person cannot sign in, worth seeing before an admin tells them to try.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/](/resources/js/Pages/Manage/Leads/Partials/Tabs/) — one self-contained component per tab body, rendered by **both** surfaces above. The Property Portal tab nests Wealth Planning + Property Analyses + the read-only **`AiConversationsTab.vue`** (full chat thread) and **`AiDebatesTab.vue`** (the panel board + verdict), plus **`ConciergeTab.vue`**; **`ActivityTab.vue`** is a top-level tab. **`ActionItemsPanel.vue`** is the Discussion tab's sticky right rail (composer with attach + assignee dropdown, item cards with inline image/video, done toggle, author-owned edit) — it mirrors `DiscussionTab`'s dual write transport (`inertia` on the Show page, axios + `refresh` in the modal).
  - **`ActivityTab.vue`** — summary cards + a category-chip filter strip + search / date range / type multi-select, each round-tripping (debounced ~300ms on search) to `GET {id}/activity`; the timeline renders a category tag, whitelisted `meta` chips and the actor (`by <admin>`) per row, and colours every row via [`utils/activityColors.js`](/resources/js/utils/activityColors.js) — a single dot/chip colour map covering every colour `ActivityLog::TYPES` / `::CATEGORIES` can emit (a local, partial copy of this map used to silently render new colours grey). Read-only mode (`leadReadonly` inject) drops the filter bar and fetch entirely, showing only the summary cards + the capped rows already on the payload — see the [ActivityLogger handbook](/docs/modules_handbook/shared/activity-log/readMe.md) for the endpoint + row-mapper contract.
- [resources/js/Components/AssignLeadManagerModal.vue](/resources/js/Components/AssignLeadManagerModal.vue) — the shared single-select account-manager picker, posts `assignee` to `manage.leads.assign`. Opened from the Leads list row action and the Lead Show identity header.
- [resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue](/resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue) — **"Same person, two leads?"** (2026-08-07). A row action on the index and a **Merge duplicate** header button on Show, both `isAdmin`-only. It exists because every automatic filer keys off a contact-key *collision*, and two records for one human very often share **no key at all** — so nothing but a human can raise them. Picks the other lead through the existing `manage.leads.search` typeahead, `POST`s `manage/leads/{id}/merge-request` ([`proposeMerge`](/app/Http/Controllers/Manage/Leads/LeadsController.php)) which **only files the pair**, then mounts the merge queue's own [`MergeReviewModal`](/resources/js/Pages/Manage/People/MergeRequests/Partials/MergeReviewModal.vue) on that pair id — so the merge itself runs through one UI with one set of guards, never a second path. Full rules + the survivor read-back: [Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md).
- [resources/js/composables/useLeadModal.js](/resources/js/composables/useLeadModal.js) — the singleton driving the modal (`openLead` / `closeLead`, the `?lead=` param, the close-on-navigate rule) plus `keepOpenDuringVisit()`, the bracket that lets a save made inside the modal refresh its host page in place.
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar nav entry. `/manage/lead-distribution` is a prefix of the pinned **Setting** entry, not of Leads: the routing tables are configuration, so the footer lights up there.
- **Distribution Setting** — a shortcut button on this page opening `/manage/lead-distribution`, the **Lead Distribution** tab of the Setting hub ([Components/SettingTabs.vue](/resources/js/Components/SettingTabs.vue)). That tab fronts the strip **Lead Tiers / Salesperson Tiers / Rules / [Groups](/docs/modules_handbook/manage/people/groups/readMe.md)** ([Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue), section `lead-distribution`). The first three are panels on one page selected by `?tab=`; Groups is its own page. Every page in the section mounts `SettingTabs` above the strip, so the hub is the way back — there is no separate Back to Leads header.

**Migrations**
- [database/migrations/2020_05_25_042762_create_leads_table.php](/database/migrations/2020_05_25_042762_create_leads_table.php) — `user_id` (unique) + status (attribution columns dropped by the move migration below).
- [database/migrations/2026_07_06_000001_add_assigned_admin_to_leads_table.php](/database/migrations/2026_07_06_000001_add_assigned_admin_to_leads_table.php) — `assigned_admin_id` (nullable, indexed) — the lead's single **account manager** (and the lead-ownership key for `LeadVisibility`). Backfilled from the old subscription-managers pivot by `2026_07_18_000001_backfill_lead_account_manager_from_subscriptions` (which `2026_07_18_000002_drop_member_subscription_managers_table` then drops).
- [database/migrations/2026_07_21_000001_add_properties_owned_to_leads_table.php](/database/migrations/2026_07_21_000001_add_properties_owned_to_leads_table.php) — `properties_owned` (`unsignedTinyInteger`, nullable, indexed): how many properties the person owns. A **declared** fact (captured from the person / a legacy CRM export), which is why it lives on `leads` and not on `lead_enrichments` — the enrichment row is regenerated by `EnrichLeadJob` (`updateOrCreate`) and would eventually overwrite it. ⚠️ `NULL` (never asked) and `0` (owns none) are **different** — read it with a null check, never a falsy check. Written on create via `LeadRepository::create()` (`lead.properties_owned`) or later via `LeadRepository::setPropertiesOwned()`. **No longer a Leads index column (removed 2026-09-17, was labelled "AI Owner").** Its only writer in practice is the 2026-07-21 legacy CRM import, and in those files 8,057 of the 8,947 `property_own = 0` rows carry no AI profile at all, so on screen a `0` mostly meant "never analysed", not "owns none". The value is kept, and the drawer's *Properties owned* filters still read it.
- [database/migrations/2026_09_17_200000_add_webinar_property_count_to_leads_table.php](/database/migrations/2026_09_17_200000_add_webinar_property_count_to_leads_table.php) — `webinar_property_count` (+ `_at`, `webinar_property_response_id`): **the lead variable `lead.webinar.property_count`** — what the person answered when a WEBINAR POLL asked how many properties they own (`Lead::WEBINAR_PROPERTIES_NONE / _ONE / _TWO_PLUS`, `NULL` = never asked). A separate fact from `properties_owned` above and never written into it: the poll has three buckets, so *2 or more* is never an exact count, and of the leads holding both, 745 carry an imported `properties_owned = 0` while the poll says they own one or more. Written by `Src\Lead\Services\WebinarPropertyCountSync` (ingestion job + hourly `leads:sync-webinar-property-counts` + the panel's own read) from the LATEST recognised answer; the registry of which polls count is `Src\Zoom\Support\PollPropertyCount`. Shown as the first card on Lead → Channel → Zoom → Insights → Webinars — see [Channel Insights · Zoom](/docs/modules_handbook/shared/channel-insights/zoom.md).
- `2026_06_18_000002_create_lead_funnels_table` — the per-registration table: `lead_id` + `event_funnel_id` + `registered_at` + attribution (utm_*, fbclid, campaign/adset/ad ids, placement, referrer, landing_url, ip, user_agent), `unique(lead_id, event_funnel_id)`.
- `2026_06_18_000003_move_attribution_from_leads_table` — backfills `leads` → `lead_funnels`, then drops the attribution columns from `leads`.
- [database/migrations/2026_08_03_400001_create_lead_action_items_tables.php](/database/migrations/2026_08_03_400001_create_lead_action_items_tables.php) — `lead_action_items` (key model: uuid + blame + soft delete; `status` defaulting to `STATUS_OPEN`, `done_at` / `done_by`) + the `lead_action_item_assignees` child pivot (`action_item_id` + `user_id`, one row per assigned admin — no uuid, hard-deleted with its item).
- [database/migrations/2026_07_29_200001_add_meta_browser_ids_to_lead_funnels.php](/database/migrations/2026_07_29_200001_add_meta_browser_ids_to_lead_funnels.php) — `fbp` + `fbc` (nullable strings, after `fbclid`): the visitor's **Meta browser identifiers**, captured at registration. `_fbp` is written by the Pixel; `_fbc` is the click id, rebuilt from the `fbclid` in Meta's `fb.1.<ms>.<fbclid>` shape when the cookie is absent (`LandingController::clickId`). They sit on the **registration row, not the lead**, for the same reason `fbclid` / `campaign_id` do — they belong to **one ad touch**, so a person who registers again through a different ad gets a different pair. ⚠️ They are stored, not merely forwarded: a **Purchase weeks later is confirmed by a gateway webhook with no browser and no cookies**, so without them Meta can only match on hashed email/phone and match quality (hence ad optimisation and reported ROAS) drops.

**Seeder**
- [database/seeds/LeadsSeeder.php](/database/seeds/LeadsSeeder.php) — sample leads, each with a linked Non-Member account + a default-funnel registration carrying its attribution.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.leads.*` group.
