# Shared · Lead ComboBox (frontend)

`resources/js/Components/LeadComboBox.vue` — the ONE way an admin picks a customer,
anywhere in the Manage portal, plus `POST /manage/leads/resolve`
([`LeadsController@resolve`](/app/Http/Controllers/Manage/Leads/LeadsController.php)) and
[`ResolveRequest`](/app/Http/Requests/Manage/Leads/ResolveRequest.php), the find-or-create
endpoint behind its *Create a new lead* hatch.

> **A shared *frontend* component, not a `Src\Common` service** — filed under `shared/` for
> the same reason [`ImageLightbox`](/docs/modules_handbook/shared/image-lightbox/readMe.md)
> is: thirteen unrelated surfaces depend on it (Zoom, Calls, F2F, Calendar, Payments,
> Sales, Leads), so its contract cannot live inside any one of them without the other
> twelve maintainers never finding it. **Read *Reference usage* before mounting a
> fourteenth**, and before adding any new screen that names a customer.

## What it does

Type a name, email or phone; pick the customer. When they are not in the system yet,
*Can't find them? Create a new lead* opens three fields in place and the picker comes back
holding the person — without leaving the screen for the Leads page and back.

## How it works

**One picker, not a convention.** Before this, thirteen screens each wired
`<ComboBox search-url="/manage/leads/search">` by hand, and exactly **one** of them
(the Add-a-booking modal) offered the create hatch. The other twelve dead-ended. Sharing
the search props is the small half of the win; sharing the *create* path is the load-bearing
half, because of the next section.

**Creating never inserts — it resolves.** The hatch posts to `/manage/leads/resolve`, which
runs [`LeadLinker`](/docs/modules_handbook/shared/lead-linking/readMe.md) exactly as every
other lead writer does. Typing somebody the CRM already holds **matches** them instead of
minting a second record. This is the whole reason the endpoint exists rather than the
component calling the ordinary create: thirteen entry points able to mint a lead, each
resolving identity its own way, would seed duplicates from all of them at once — and a
customer whose history is split across two records fails **silently**. Nothing errors; the
call log simply stops being complete.

**A match is announced, never silent.** The admin opened the create form believing this
person was new. Landing on an existing record without saying so is how a call gets filed
against the wrong customer, so the box shows *Matched an existing lead — Jane Tan · …*
until the next pick clears it. This mattered less when one screen could match; it matters
thirteen times more now.

**`ResolveRequest` differs from the Leads index create in exactly one branch.** It extends
[`StoreRequest`](/app/Http/Requests/Manage/Leads/StoreRequest.php) and overrides only
`onKnownPerson()`. The staff refusal and the email-vs-phone conflict refusal are shared and
deliberately **not** overridable — neither surface may ever turn a colleague, or two
different people, into one lead.

| The typed keys resolve to | Leads index (`StoreRequest`) | Picker (`ResolveRequest`) |
|---|---|---|
| Nobody | creates | creates → `matched: false` |
| Somebody the admin may see | **refuses**, naming them ("open that lead instead") | **matches** → `matched: true`, no write |
| Somebody the admin may NOT see | refuses, naming them | **refuses without naming them** |
| A staff member | refuses | refuses |
| Email of A + phone of B | refuses | refuses |

**Why an out-of-scope person is refused rather than returned.** Search is scoped by
[`LeadVisibility`](/src/Auth/Support/LeadVisibility.php), so an own-scope agent cannot find
another agent's customer. Returning that customer through the *create* path would make it
the back door: guess the email, get the lead, attach your call or payment to somebody
else's customer. Creating a second lead instead would be the duplicate this design exists
to prevent. Refusing is the only answer that is neither — and the message deliberately does
**not** name them, which is strictly less than the Leads index discloses today.

**Two rules the endpoint learned the hard way** (branch auditor, before merge):

- **A just-minted lead is assigned to whoever created it, when they could not otherwise
  see it.** A new lead has no assignee and no engagement, so `LeadVisibility::allows()`
  is false for an own- or team-scope actor — which is exactly who the pickers exist for
  (`sales-agent` holds `manage-leads` + `view-leads-own`). Without the assignment the
  agent got a 403 holding nothing *after* the account was written, and every retry then
  hit "not one of your leads": locked out of a customer they added seconds earlier. An
  actor who can already see it is left alone — creating a lead on someone's behalf must
  not quietly make you its manager.
- **A MATCH must not re-stamp attribution** (`onlyOnCreate: true`, where the Leads index
  passes `false`). The index can afford `false` because it refuses a known person, so the
  flag never fires on an existing lead. Here it would: `attach()` is `firstOrCreate` keyed
  on `(lead_id, event_funnel_id)`, and a lead whose attribution is entirely funnel-bound —
  every webinar registrant, **838 of them in the production restore** — has no
  `(lead, null)` row, so merely picking them in a ComboBox would insert one reading
  "Other". The index's source counts and source filter both key on those rows, so a funnel
  lead would start reporting as Other with nothing failing.

**The marketing source is not a prop the pages thread through.** The endpoint defaults to
`LeadFunnel::SOURCE_OTHER` — "an admin typed it" — so no controller has to send `sources` /
`defaultSource` just because a screen owns a lead picker (five did, and no longer do). The
one exception is the **VSL roster**, where the surface itself *is* the attribution: a person
hand-added there came from the funnel, and filing them under Other would quietly under-count
that funnel in every report. That is what the `source` prop is for; do not reach for it
anywhere else.

## Reference usage

The common case is one line — no search url, no key props, no create wiring:

```vue
<LeadComboBox
    v-model="form.lead_uuid"
    :selected="record.lead ? { uuid: record.lead.uuid, name: record.lead.name } : null"
    :invalid="!!form.errors.lead_uuid"
/>
```

`v-model` is the lead **uuid**. `selected` is the pre-hydrated `{ uuid, name }` for edit
mode (it paints the label without a refetch — it does **not** seed the value, so set
`form.lead_uuid` too, or you get a filled box and a dead submit button). `@select` receives
the whole lead row when a caller needs the name or email as well.

Three props exist for one caller each, and each is a deliberate exception:

| Prop | Its one consumer | Why |
|---|---|---|
| `:allow-create="false"` | [`MergeLeadPickerModal`](/resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue) | It names the OTHER half of a duplicate **pair** — both people are already in the CRM by definition, so offering to mint a third answers the opposite question. |
| `start-in-create` | [`LogReferralModal`](/resources/js/Pages/Manage/SalesProjects/Partials/LogReferralModal.vue) | A referral is captured mid-call from a name and a number, so typing is the common case and searching the fallback. Making the common case the second click is how a form stops being filled in. |
| `:source` | [`VslLeadsTab`](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/VslLeadsTab.vue) (via `AddLeadToProjectModal`'s `lead-source`) | See the attribution note above. |

**A modal reused across rows must remount the picker** — bind `:key` to a counter the
parent bumps on open, as `AddLeadToProjectModal` and `LogReferralModal` both do. Without
it the previous row's person, or a half-typed create form, is still on screen when the
next row opens it.

**The create hatch hides itself** for an admin without `manage-leads` (via
[`usePermissions`](/resources/js/composables/usePermissions.js)) — the endpoint would 403,
and a button that 403s is a bug, not a hint. Search still works on the `view-leads-*` grant
the surrounding page already required.

**Do NOT** add a fourteenth hand-wired `<ComboBox search-url="/manage/leads/search">`, and
do NOT add a second create-a-lead endpoint. Both are how the thirteen drifted apart the
first time.

## Related files

**Frontend**
- [resources/js/Components/LeadComboBox.vue](/resources/js/Components/LeadComboBox.vue) — the component.
- [resources/js/Components/ComboBox.vue](/resources/js/Components/ComboBox.vue) — the generic typeahead it wraps (still used directly for non-lead pickers: projects, agents, banks).
- [resources/js/composables/usePermissions.js](/resources/js/composables/usePermissions.js) — gates the create hatch.

**Backend**
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `search()` (the list) and `resolve()` (find-or-create); both render rows through the shared `leadOption()`, so a created lead lands in the box the same shape as a searched one.
- [app/Http/Requests/Manage/Leads/ResolveRequest.php](/app/Http/Requests/Manage/Leads/ResolveRequest.php) — the one overridden branch.
- [app/Http/Requests/Manage/Leads/StoreRequest.php](/app/Http/Requests/Manage/Leads/StoreRequest.php) — the shared staff / conflict refusals, and `onKnownPerson()`.
- [tests/Feature/Lead/ResolveLeadTest.php](/tests/Feature/Lead/ResolveLeadTest.php) — the duplicate-prevention proof: a differently-cased email and a differently-formatted phone both resolve to the existing lead with `Lead::count()` unchanged.

**Consumers (13)** — Sales: [`AddLeadToProjectModal`](/resources/js/Components/Sales/AddLeadToProjectModal.vue), [`LogReferralModal`](/resources/js/Pages/Manage/SalesProjects/Partials/LogReferralModal.vue) · Zoom: [`ZoomRecordingDetailModal`](/resources/js/Components/ZoomRecordingDetailModal.vue), [`Recordings/Index`](/resources/js/Pages/Manage/Zoom/Recordings/Index.vue) · Calls: [`CallFormModal`](/resources/js/Pages/Manage/Calls/History/Partials/CallFormModal.vue), [`UploadRecordingPanel`](/resources/js/Pages/Manage/Calls/History/Partials/UploadRecordingPanel.vue) · F2F: [`F2fFormModal`](/resources/js/Pages/Manage/F2f/Showroom/Partials/F2fFormModal.vue), [`UploadRecordingPanel`](/resources/js/Pages/Manage/F2f/Showroom/Partials/UploadRecordingPanel.vue) · Calendar: [`EventDetailModal`](/resources/js/Pages/Manage/Calendar/Partials/EventDetailModal.vue) · Payments: [`ResolveClaimModal`](/resources/js/Pages/Manage/Payment/Claims/Partials/ResolveClaimModal.vue), [`PaymentLinkFormModal`](/resources/js/Pages/Manage/Payment/Links/Partials/PaymentLinkFormModal.vue), [`PurchaseHistoryFormModal`](/resources/js/Pages/Manage/Payment/PurchaseHistories/Partials/PurchaseHistoryFormModal.vue) · Leads: [`MergeLeadPickerModal`](/resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue).
