# Engagements & Bookings — the Property Booking product line

**Portal:** Manage · **Namespace:** `Src\Engagement` ·
**Route groups:** `manage.sales-projects.*` · `manage.engagements.*` · `manage.bookings.*` ·
`manage.closing-modes.*` · `manage.pipeline-roles.*` · `manage.leads.referrals.*` ·
**Surfaces:** `/manage/sales-projects` (the hub, five `?view=` panels + a project Show page),
the Lead Show **"Pipeline"** tab, and **Setting → Closing Modes**

**Nav:** Sales → **Property Booking** — the third of the Sales hub's main tabs
([`SalesTabs.vue`](/resources/js/Components/SalesTabs.vue)), whose three stages render as a
`StageTabs` chevron rail on `/manage/sales-projects`. No sidebar entry of its own
(GUIDELINES §15).

> **This file is the map.** It carries only what a reader needs before choosing a chapter: what an
> engagement is, the shape of the hub, and where each subject is written down. Everything else —
> the statuses, the money, the schema, the surfaces — lives in the chapters below, so no single
> page has to be re-read to change one thing.

## What it does

Models the real sales lifecycle of a **property agent team**: a lead is worked **per project**,
not once overall. An **engagement** is one `(lead, project)` sales cycle, worked by a team whose
roles are **defined by admins at runtime**, and it runs a single ordered status ladder from *New*
to either *Converted* or *Lost*. A lead interested in two projects has **two engagements**, each
with its own status and its own team.

This is the **reform of the old `Lead::status`**: the lifecycle state that used to be one flat
enum on the lead now lives on the engagement, one row per project. `leads.status` survives only as
a **synced roll-up** so existing list / export / inbox readers keep working — it is no longer
edited directly.

When a deal closes, the closer records a **Booking** (the unit + its prices + the SPA paperwork),
and the deal's **commission** and its **split between teammates** are derived from that booking,
the project's rate, and the team — none of it stored as an amount.

> **Phase 1 scope.** This ships the per-project pipeline + assignment + Booking/SPA + the
> commission configuration. The wider "Lead Pool Engine" from `docs/lead_temp/LEAD_LIFECYCLE_SPEC`
> — fresh/recycle/cancelled/converted categories, A/B/C/D buckets, `distribution_count`,
> caller/closer skill tiers, pull-based *Request Leads*, weekly promotion, AI-bot auto-routing —
> is **deferred to Phase 2**, and will attach to the engagement (per-project), not to the lead.

## The chapters

| File | Read it when you need |
|---|---|
| [lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) | the **Engagement** itself — its three status maps, the stage derivation, assignment + the standing team, `EngagementRepository`, `ChangeEngagementStatus`, the `leads.status` roll-up, and how holding a role grants lead visibility |
| [booking.md](/docs/modules_handbook/manage/engagement/booking.md) | the **Booking** — the unit, its prices, the *derived* SPA state, `BookingRepository`, and the status ⇄ booking invariant (including the two states that violate it today) |
| [commission.md](/docs/modules_handbook/manage/engagement/commission.md) | the **money** — how one commission figure is produced, and how it divides between the people on the deal |
| [closeout.md](/docs/modules_handbook/manage/engagement/closeout.md) | **after conversion** — the SPA & LO paperwork (dates, the LO letter, who attended) behind the red "!", and the commission **payout**: gross → −8% SST → net → claims → balance |
| [closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md) | **Setting → Closing Modes** — the admin-editable role + closing-mode vocabulary that the team UI and the whole split are built on, and the mode ⟺ project-fee-receipt rule |
| [sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md) | the **surfaces** — what a sales project is as data, and every panel of `SalesProjectsController`, its tables, sorts and export |
| [referrals.md](/docs/modules_handbook/manage/engagement/referrals.md) | **stage 3** — who counts as a customer, the ask-for-a-referral worklist, and the attribution columns on `leads` |
| [imports.md](/docs/modules_handbook/manage/engagement/imports.md) | the **two CSV importers** — project info on the index, legacy bookings on a project's Show page |
| [perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md) | the **boundary** — every route + permission, both scoping rules and the surfaces that skip them, the full column tables, and who reads/writes this data from outside the module |
| [retired.md](/docs/modules_handbook/manage/engagement/retired.md) | the **dead list** — columns, flags, tables and components that still exist but are no longer load-bearing, and whether each is safe to drop |

## The Property Booking hub — three stages, one page

`/manage/sales-projects` is not a list with tabs; it is a **funnel**, and the tab strip says so.
[`SalesTabs.vue`](/resources/js/Components/SalesTabs.vue) feeds [`HubTabs`](/resources/js/Components/HubTabs.vue)
a `stageTabs` config, which renders [`StageTabs.vue`](/resources/js/Components/StageTabs.vue) — a
chevron rail whose arrow shape carries the ORDER, so no label ever has to spell it out (the
strip's predecessor read "Pipeline: Property Match", which is the smell it replaces).

**Each stage owns every `?view=` of its own pivots**, mapped back through `BOOKING_VIEW_STAGES`:

| # | Stage | Label | `?view=` | What it is |
|---|-------|-------|----------|------------|
| 1 | **Pipeline** | Property Match | `match` | Buyer-quiz submissions, addable straight into a project's pipeline |
| 2 | **Sales** | Bookings | *(none — the default)* + `projects` | Booked / Converted / Lost deals. Two **pivots** of one stage: *By Booking List* and *By Project* (the catalogue) |
| 3 | **Referral & Repeat** | Customers | `referrals` + `referral-chain` | The loop: people who already bought → who to ask for an introduction, and who could buy again |

⚠️ **There is a fifth `?view=` value and it is not a panel.** `?view=pipeline` **302-redirects** to
`manage.sales.pipeline` (the cross-project kanban board, now a Dashboard-hub page), forwarding
every other query parameter — an old bookmark keeps working. Do not add a `pipeline` panel back to
this page.

Stage colours are a value ramp inside ONE family (`navy-800` → `brand-700` → `brand-500`), never
separate hues, and every fill is solid: `clip-path` leaves no border, so a pale tint would lose the
chevron silhouette. Counts come from `hubStageCounts()` and are sent on **every** view, because the
rail is always on screen.

**A pivot is not a stage.** A control that re-pivots only ONE stage — stage 2's *By Booking List* /
*By Project*, stage 3's *Customers* / *Referral Chain* — stays an in-page segmented control styled
quieter than the rail.

### Property Booking is one of the Sales hub's product lines

Every line in the Sales hub carries the same three stages, because that is the shape of the
business: strangers become customers, and customers bring more strangers. A stage that is not built
yet renders as a `{ soon: true }` chevron, so each line displays where it is going rather than a
shorter funnel.

As of 2026-10-02 the hub has **eight main tabs — Payment (pill sub-tabs, not a product line) plus
seven product lines** — and **Property Booking is no longer the only line with all three stages
built: Renovation has all three too** (Enquiries → Jobs → Owners). Rental has two (Rental Estimates
→ Tenancies, Landlords *soon*); Memberships (Memberships) and MLTA (Converted Buyers) have one each;
Management and Customized AI have none (a placeholder page per line). ⚠️ MLTA's one stage is fed by
this module but reads **`won_at` in the current calendar year** — so in practice it lists deals that
were moved to Converted this year, not every Booked one (see
[perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md)).

⚠️ **That product-line table is not this module's to own.** It is declared in `PRODUCT_STAGES` in
[`SalesTabs.vue`](/resources/js/Components/SalesTabs.vue) and spans lines belonging to several other
modules (Memberships, MLTA, Rental Estimates, Renovation …). Read it there — the summary above is a
dated snapshot, and a copy here goes stale the moment a line is added, which is exactly how this
handbook came to list six lines when the code declares eight main tabs. (The component's own header
comment still says only Property Booking has all three stages — that comment is stale, not the
code.)

## Perimeter at a glance

Six route-name groups, each gated differently. **The full table — every route, its permission, and
which scope the controller re-checks — is in [perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md);**
re-derive the list with `php artisan route:list --name=sales-projects` (and the five other prefixes)
rather than trusting a number written here.

| Group | Gate to view | Gate to write | Owns |
|---|---|---|---|
| `manage.sales-projects.*` | `view-projects` | `manage-projects` (project CRUD, catalogue pairing, rent basis, Post-VP schedule) / `manage-leads` (leads, booking import, the buyer's-unit writers) | the hub's five panels, the project Show page, project CRUD, both entry points for adding a lead, both exports (`.export` per project, `.bookings.export` cross-project), catalogue pairing + Property Preview, the buyer's-unit columns, the project's Post-VP schedule |
| `manage.engagements.*` | any `view-leads-*` | `manage-leads` | open / status / lost-reason correction / assign / Zoom closer / reopen / remove, the booking create, the payment-candidate picker |
| `manage.bookings.*` | any `view-leads-*` | `manage-leads` | booking update + cancel, and the loan-offer files (upload / view / delete) |
| `manage.closing-modes.*` | `view-closing-modes` | `manage-closing-modes` | the modes and their role pools |
| `manage.pipeline-roles.*` | `manage-closing-modes` | `manage-closing-modes` | the role vocabulary (writes only — the read is the closing-modes page) |
| `manage.leads.referrals.*` | any `view-leads-*` | `manage-leads` | stage 3's writes. They live in the **leads** group because they are lead writes ⚠️ and `{id}` means a **different lead** in each of the three |

Two scoping rules apply on top of the permissions and are **not** interchangeable: `GroupScope`
partitions **projects** by agency, `LeadVisibility` partitions **leads** by who may see the person.
Both are re-checked inside the controllers, per record — see
[perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md), which also names the surfaces
that apply neither.

## Everything here is derived, and that is the design

Four things a reader will look for a table of, and not find one, because each is computed from
facts the deal already carries:

| What | Derived from | Written up in |
|---|---|---|
| The coarse **stage** | the status, via `Engagement::stageForStatus()` | [lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) |
| `leads.status` | the lead's engagements, via `SyncLeadStatusFromEngagements` | [lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) |
| The **SPA state** | the deal's own facts, via `Booking::spaState()` | [booking.md](/docs/modules_handbook/manage/engagement/booking.md) |
| The commission **amount** and its **split** | the booking's price × the project's rate, then the closing mode's role pools × each holder's share | [commission.md](/docs/modules_handbook/manage/engagement/commission.md) |

A reader who learns this once stops hunting for the missing tables — and stops adding a stored
column that can contradict the facts it restates. `bookings.spa_status` is what that mistake looked
like the first time; see [retired.md](/docs/modules_handbook/manage/engagement/retired.md).

## Related modules

- [Post-VP Tracker](/docs/modules_handbook/manage/post-vp/readMe.md) — what happens to a booked unit AFTER the sale: 收匙 → 验房 → 缺陷 → 装修 → 招租 → 出租中 → 卖出, on the project Show page's Post-VP tab and the buyer's dashboard. A tracked unit is a `Booking` (Active or Completed).
- [Leads (Manage)](/docs/modules_handbook/manage/leads/readMe.md) — the lead this pipeline hangs off; its Show page hosts the **Pipeline** tab, and `leads.status` is a roll-up of engagements.
- [Property Match](/docs/modules_handbook/main/property-match/readMe.md) — stage 1. The public buyer quiz, its decision engine and the standalone person workbench belong to that module; this hub only adds one surface over the same rows.
- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — the identity foundation. A lead *is* a user, and every assignee id is a `users.id`.
- [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md) — the `LeadLinker` both importers, the Add-Lead modal and the referral modal all go through.
- [Payments ledger](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md) — `engagements.purchase_history_id`, the project-fee receipt, and the admin lane that links one by hand.
- [Appointments / Calendar](/docs/modules_handbook/manage/calendar/readMe.md) — appointments carry a nullable `engagement_id` so a scheduling event groups under its deal.
- [Appointment Engine](/docs/modules_handbook/manage/appointment-engine/readMe.md) — an eighth lane that OPENS engagements (`CrmPipeline::ensureOpen()`), writes the closer role, and moves statuses forward on its own (New → Contacting → Appointment Set, or Lost from the first two). Its single "agent" IS the deal's closer, in both directions. See [lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md).
- [Revenue Journey](/docs/modules_handbook/shared/revenue-journey/readMe.md) — records a purchase's booking by calling `BookingRepository::create()` on the Sales engagement; this module stays the owner of the booking, its lifecycle and its commission. ⚠️ Its lane sends no `spa_price` (known gap, see [booking.md](/docs/modules_handbook/manage/engagement/booking.md)).
- [Renovation](/docs/modules_handbook/manage/renovation/readMe.md) — the second Sales product line with all three stages; writes `bookings.renovation_by` and links its jobs to a sold unit by `booking_id`.
- [Meta Pixel & Conversions API](/docs/modules_handbook/manage/meta-ads/pixel/readMe.md) — ⚠️ when a booking becomes a confirmed, paid sale is the moment a Meta `Purchase` event should fire, with the amount, so ads optimise for people who *spend* rather than people who merely register. Not built; the pixel handbook holds the payload shape and its prerequisites.

### Column chooser — the Leads list's columns on a project's Leads tab (2026-09-27)

`Components/Sales/EngagementTable.vue` grew a **Columns** button (only where the host passes
`column-prefs-key`): every column of the table can be hidden, and when the page also passes
`lead-rows` + `lead-column-props` the **Leads list's own columns from Status to Action** (Membership,
CLV, Book / Convert / Drop / Total, Record / Declare / Report, the seven Readiness areas, Rep,
Engaged, Zoom / Phone / Message / Portal / Showroom / AI Call, Action) are offered too — hidden by
default, remembered per browser under `engagement-table:columns:{key}`. They render through
`Components/Leads/LeadListCell.vue`, which mounts the Leads list's OWN cell components, and their
data is the Leads list's OWN row: `LeadsController::listRowsForLeads($leadIds, $user)` — a public
wrapper over `withListColumns` + `listRows`, called by `SalesProjectsController@show` for the ~25
leads on the page (about 1.3 s for 5 leads in a tinker run; the Leads index pays the same per page)
and keyed by lead uuid (`leadRows` prop). Column defs live in `Components/Leads/leadListColumns.js`
with keys prefixed `lead_` so `status` never collides with the engagement's own. A lead the viewer
may not see on the Leads list gets an em dash in every lead column. The Sales stage's booking list
(`SalesProjectsController@bookingsView`, `column-prefs-key="booking-list"`) passes the same props
since 2026-10-02, with no project id — its rows span projects, like the CEO's Property Closing list.
The vocabularies come from one helper, `SalesProjectsController::leadColumnProps()`.

The **1-1 Zoom** column (`lead_zoom_1on1`, Engagements band) answers "has a one-to-one Zoom been
set up with this lead?": the latest confirmed booking made through a host's scheduling link
(`calendar_bookings`, green while upcoming, grey once held) and, beneath it, the latest **Zoom
Meeting post Booking Payment** scheduled from the calendar (`zoom_meetings.purpose = 2`). Both facts
are attached by `LeadsController::listRowsForLeads` as `zoom_1on1` / `zoom_post_payment`. On a
project's page the call must be THIS project's: `listRowsForLeads(…, $project->id)` keeps only calls
paired to one of this project's engagements (`zoom_meetings.engagement_id`, 2026-09-28) or paired to
none, so a call scheduled for another pipeline never reads as this deal's; the chip's tooltip names
the paired project.

**Sales Zoom** (column, 2026-09-28) is that fact as an always-shown column of the project's own
table, beside Commission: the same chips the 1-1 Zoom lead column renders (`LeadListCell` with the
`lead_zoom_1on1` def, fed `leadRows[lead_uuid]`). Mounted whenever `leadRows` is given — the
project page, the Sales stage's booking list and the CEO's Property Closing list. On the two
cross-project lists the chip is the lead's latest post-payment call, whichever pipeline it was
paired to (its tooltip names the project). **The cell has two states
(2026-10-02):**
- **Has a Zoom → the date chip and nothing else, and the chip is the control.** Clicking it opens
  the Sales Zoom modal (below), where the call is changed, unlinked, or a new one scheduled. A
  pencil appears on hover, positioned outside the flow so the chip never shifts. ⚠️ Do not put a
  second link under a filled cell — the first version printed "Change" under every filled row, and
  it read as noise repeated down the whole column (founder feedback, same day). Viewers without
  `manage-leads` get the plain chip.
- **Empty → one line: `Schedule · Link`** — *Schedule* (`manage-calendar`) opens the same form as the
  row action; *Link* (`manage-leads`) opens the modal. Neither permission → an em dash.

**Link an existing Zoom meeting — the Sales Zoom modal** (2026-10-02). *Schedule* can only create
a NEW call, so a call that already existed could never be shown as this deal's sales Zoom: one
held straight from Zoom (synced into `zoom_meetings` with no deal and usually no lead — 202 such
rows on prod), or one scheduled earlier as an ordinary meeting.
[`Components/Sales/SalesZoomLinkModal.vue`](/resources/js/Components/Sales/SalesZoomLinkModal.vue)
picks one, and is also where a filled cell's call is changed.
- **Layout:** a green **Linked to this deal** block first (with **Unlink**) — a modal opened from
  the date is a question about THAT call — then the search and the candidates, whose button reads
  **Link**, or **Switch to this** while something is linked. The footer offers **Schedule a new
  Zoom instead** (`manage-calendar`): a filled cell no longer shows *Schedule*, so the modal emits
  `schedule` and `EngagementTable` opens the calendar's form for the same row.
- **What it offers** (`GET manage/engagements/{id}/sales-zoom/candidates?q=`, JSON, fetched when
  the modal opens): `linked` — what the deal holds now, **never narrowed by the search**;
  `meetings` — this LEAD's other meetings, shown in two groups, **Scheduled — not held yet** and
  **Past meetings** (`ZoomMeeting::hasHappened()`); `unlinked` — the latest 20 meetings linked to
  **no lead**, `LeadVisibility`-scoped. The search (topic or host) narrows the last two. Cancelled
  meetings and webinars are never offered, and neither is a meeting that belongs to a **different**
  lead — the `store` action refuses that too, so a hand-written POST cannot file one person's call
  under another person's deal. A row flagged `floating` is a post-payment call tied to no deal —
  it may be the date in the cell without being linked here, and the row says so.
- **What linking writes** (`POST manage/engagements/{id}/sales-zoom` →
  `ZoomMeetingRepository::linkEngagement()`): `zoom_meetings.engagement_id` = this deal and
  `purpose` = `PURPOSE_POST_BOOKING_PAYMENT` — the same two columns *Schedule* writes at creation,
  and the purpose is what this column reads. A lead-less meeting also adopts the deal's lead
  through `linkLead()`, so its recordings follow. A meeting paired with ANOTHER deal of the same
  lead is shown with a warning and **moves** here.
- ⚠️ **Linking REPLACES.** Every other meeting paired with the deal is un-paired in the same
  transaction. The cell shows one call per deal — the latest — so picking an older meeting while a
  newer one stayed paired would change nothing on screen. (*Schedule* does not replace; two paired
  calls can still arise that way and the modal lists both under *Linked*.)
- **Unlink** (`DELETE manage/engagements/{id}/sales-zoom/{meeting}`) clears the pairing and sets the
  purpose back to general — ⚠️ a post-payment call paired to no deal still shows on every project
  of that lead, so leaving the purpose would make Unlink look like it did nothing. The lead link
  stays.
- Gated by **`manage-leads`** (it pairs two existing records, like Zoom Closer), not
  `manage-calendar` (which *Schedule* needs because it creates a meeting at Zoom).
- Controller [`SalesZoomController`](/app/Http/Controllers/Manage/Engagements/SalesZoomController.php) ·
  request [`SalesZoomRequest`](/app/Http/Requests/Manage/Engagements/SalesZoomRequest.php) · pinned by
  [`SalesZoomLinkTest`](/tests/Feature/Engagements/SalesZoomLinkTest.php).

**Schedule Sales Zoom Meeting** (row action, 2026-09-28; the button reads **Zoom**, the full name is its tooltip; row actions run remark → Zoom → book → delete) opens the calendar's own Zoom form locked
to *Zoom Meeting post Booking Payment* and pre-paired with the row's sales opportunity (lead ·
project), so the meeting is created on the chosen host's calendar naming this pipeline and the 1-1
Zoom column picks it up on the redirect back. Shown to `manage-calendar` holders; the server still
decides whether the scheduler and host are Zoom account users. How the pairing works, and the
Lead ⇄ Sales opportunity switch the calendar form gained with it, is in the Calendar handbook
(*Zoom meeting purpose*).
