# The Sales Projects surfaces

**Portal:** Manage · **Controller:** [`SalesProjectsController`](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php) — by far the largest in the module ·
**Routes:** `manage.sales-projects.*` (13) + `manage.sales.pipeline` ·
**Gate:** `view-projects` on every route; `manage-projects` to write a project; `manage-leads` to
touch a lead

Part of the [Engagements & Bookings handbook](/docs/modules_handbook/manage/engagement/readMe.md).

## What it does

One controller serves **the whole Property Booking hub**: five `?view=` panels of
`/manage/sales-projects`, a project's Show page, the cross-project kanban board, the Leads export, a
typeahead search endpoint, and the two ways a lead is added to a project.

The catalogue and the pipeline were briefly two pages. They are the same thing seen twice, so they
were merged.

## How it works

### What a "sales project" IS

**Not its own table.** A sales project is a row in the shared `projects` table
([`Src\Property\Project`](/src/Property/Project.php)) with `origin = 'custom'` and
`catalog_project_id = NULL`. What makes it a *sales* project is only which writer created it and
which surface reads it. The same table also holds **catalogue-linked** projects
(`origin = 'catalog'`), which the marketing and subsale suites use.

The full `projects` table belongs to the project-catalogue documentation; this suite owns **four
columns**:

| Column | Type | What it decides here |
|---|---|---|
| `commission_rate` | `decimal(5,2)` nullable | the percentage every commission on this project is worked out at |
| `commission_basis` | `unsignedInteger` **NOT NULL**, default `2` (Net) | which booking price that percentage applies to (`1` SPA / `2` Net) |
| `vp_at` | `date` nullable | vacant possession |
| `is_highlighted` | `boolean` | pins the project to the top of every list |

⚠️ **A catalogue-linked project owns NONE of its own identity facts.** Its name, developer,
location and price band resolve through `canonicalName()` / `canonicalDeveloper()` /
`canonicalLocation()` / `canonicalPriceFrom()` / `canonicalPriceTo()` to the `catalog_projects` row;
the `projects.*` columns are a **stale compatibility cache that is never read back**. Always go
through the canonical helpers (or `canonicalFacts()`, the 5-key DTO every card uses) — a linked
project is not even findable by its stale `projects.name`, because `scopeSearchCanonical()` matches
those rows through the catalogue instead. Note also that a linked project's "price band" is a
**percentile** (`price_25` / `price_75`), not a developer price list.

Sorting by name uses `orderByCanonicalName()` — **a bounded `CASE` and nothing else**: the
id→name map is plucked first and inlined as bindings, so there is no join and no correlated
subquery either.

⚠️ **Name search and name sorting were MASTER-ONLY until 2026-09-14.** `matchingCanonicalIds()`
and `orderByCanonicalName()` asked `CatalogProject::query()`, which is pinned to the `catalogue`
connection — so on a deployment whose catalogue is a separate database, a project linked to a
catalogue row **this platform created itself** was never matched by `searchCanonical()` and sorted
by its stale `projects.name`. Both now resolve through
`CatalogueFederationService::matchingReferenceIds()` / `referencedRows()`, which ask whichever
database owns each id. Invisible on a collapsed box and to the whole test suite, which is why it
survived so long.

**Use `displayName()` for any message ABOUT a project** — `canonicalName()`, falling back to the
`projects.name` compatibility cache and then `'Untitled project'`. Every flash toast in the suite
does (`update`, `destroy` — both the still-has-pipelines error and the success —, `highlight` both
directions, and `storeLead`'s two branches). They used to print `$project->name`, the stale column
this section tells you never to read back: a linked project renamed in the catalogue toasted its
old name, and one whose cache column was empty toasted `Project ''`. (`store()`'s toast still uses
`$project->name`, correctly — that path only ever creates a non-linked project.)

⚠️ **Writes go through [`SalesProjectRepository`](/src/Engagement/Repositories/SalesProjectRepository.php),
NOT the shared `Src\Property\ProjectRepository`.** The Property repository was reoriented to be
**catalogue-only**: its `create()` now requires a `catalog_project_id` and derives every field from a
`CatalogProject`, so calling it here throws
`InvalidArgumentException: A working project requires a catalogue reference`. The sales side owns its
own thin writer for the plain `origin = custom` row this suite needs. A project created here does
**not** appear on the marketing/subsale project pages (those need a `FocusProject` wrapper).

⚠️ **A custom project always resolves ZERO floor plans.** `Project::floorPlans()` keys on
`catalog_project_id` → `catalog_project_id`, and its **local** key is null on a custom project.
That is also why the booking form's floor-plan `exists` rule
(`… where catalog_project_id = $catalogProjectId ?? 0`) refuses every id there: correct, because
there are none to offer.

**`floorPlans()` is a `Src\Common\Relations\FederatedHasMany`, not a plain `hasMany`** (2026-09-14).
The plans live wherever their catalogue project lives — the shared master, or this site's database
for a project the platform created itself — and the children cannot say which, because a site also
holds frozen MIRRORS of master rows under the master's own ids. For ONE project the relation is
built on the owning connection **up front**, which is the only way `->floorPlans`,
`->floorPlans()->get()` and `->count()` all read the right database; an eager
`with('floorPlans')` over a mixed batch splits per key inside the relation. Two consequences worth
knowing:

- ⚠️ **Never `withCount('floorPlans')` on a working project.** A count aggregate compiles a
  correlated subquery on the PARENT's connection, which the relation's federation cannot reach.
  For a list use **`Project::floorPlanCounts($ids)`** — at most two grouped `COUNT`s, one per
  database, never a join across the boundary. That is what the `?view=projects` table uses.
- ⚠️ **Do not key a merged plan collection by integer `id`.** Both exports do
  (`exportBookings` and the per-project export flat-map plans and `->keyBy('id')`). Now that
  `floorPlans()` can return rows from two databases, a master plan and a site plan sharing an
  integer id would collide and the unit-type column could print the wrong plan name. Bounded — it
  needs a site that has its own catalogue rows AND an export spanning both — but key by `uuid` if
  that ever becomes possible here.

### The five `?view=` panels — one dispatcher

`index()` checks in this order, and the order matters:

| Check | Result |
|---|---|
| `?view=pipeline` | **302 redirect** to `manage.sales.pipeline`, forwarding every other query parameter so old bookmarks keep working |
| `?view=match` | `matchView()` — stage 1 |
| `?view=referrals` or `?view=referral-chain` | `referralView()` — stage 3, both pivots |
| anything **except** `projects` | `bookingsView()` — **the default**, stage 2's By Booking List |
| `?view=projects` | falls through to the catalogue in `index()`'s own body |

All five render the **same Inertia component**, `Manage/SalesProjects/Index`, which dispatches on the
`view` prop. Every one of them sends `stageCounts` (the rail is always on screen) and
`sources` / `defaultSource` — ⚠️ the latter because **the Add Booking modal is mounted on every view**,
and without them its source picker would be empty on one pivot only, which is the kind of gap nobody
notices.

#### 1. By Booking List (the default)

Every engagement across all projects, **defaulting to the sales result** — as of 2026-08-11 the
list is no longer hard-scoped. `?status` has three states:

| URL | Rows shown |
|---|---|
| *(no `status` key at all)* | the DEFAULT scope: `Booked / Converted / Lost` — what the boss lands on |
| `status=` *(present, empty)* | everything — the **Total** chip, i.e. "I cleared it on purpose" |
| `status[]=N` | exactly those statuses — including the pipeline ones the old list excluded |

⚠️ **The switch is the PRESENCE of the key, not its value** — `$request->has('status')`. This is the
shared §14 contract for a server-defaulted dimension (`defaulted: true` in `useResourceIndex`, which
is what makes an emptied multi-select serialize as `status=` instead of vanishing). With "empty means
default" the Total chip would hand back the very filter it just cleared, and the strip would read as
broken.

⚠️ **The `filters.status` prop echoes the EFFECTIVE scope, not the URL.** A bare visit answers with
the default trio, which is what lights three chips, fills the drawer and prints three removable
chips on a fresh page. Echoing the raw request would show three statuses of rows under a strip
claiming nothing was filtered.

The status strip is **the project Show page's, verbatim**: Total + every selectable status, banded
into the same tinted groups, counts always over EVERY status and never zeroed by the scope — but,
since 2026-09-07, **narrowed by every other filter** (see below). The
outlined chips show what the list is scoped to *right now*, so a fresh visit lights Booked /
Converted / Lost together, and Total lights alone when everything is shown. Re-picking the one
explicit status clears back to the DEFAULT scope, not to Total. Pinned by
`SalesProjectsIndexTest::test_the_booking_list_defaults_to_the_sales_result_but_can_show_any_status`.

- Filtering lives in [`BookingQueryRequest`](/app/Http/Requests/Manage/SalesProjects/BookingQueryRequest.php)
  — and in [`ProjectLeadsQueryRequest`](/app/Http/Requests/Manage/SalesProjects/ProjectLeadsQueryRequest.php),
  which **extends it** and drops only `project` (a project page IS one project). The two surfaces
  mount the same `EngagementTable`, so a filter that behaved differently on one of them would be a
  bug with no visible symptom — and two of them already had: the Show page's old hand-rolled status
  filter refused an ARRAY (a multi-select was silently ignored) and its search matched a raw phone
  string (a term with spaces or a `+` found nothing). `leadStatusFilter()` / `leadSearchTerm()` /
  `leadAssigneeIds()` were deleted with them on 2026-08-11.
- ⚠️ **An unknown `?status=999` is DROPPED, not honoured** (`BookingQueryRequest::sanitizeStatuses`,
  whitelisted against `ALL_STATUSES` so a legacy Negotiating row stays reachable). A stale bookmark
  shows everything rather than an empty table with no chip lit to explain why. It takes the VALUE,
  not the request — a `QueryRequest` wraps a Request rather than being one, so it has no `input()`.
  Filtering it in one place is also what lets the controllers echo the sanitised list back.
  search (buyer name / email / phone, project, unit no, booking no) plus a `FilterDrawer` of
  **status · project · bank · booked-date range · teammate**. ⚠️ Every booking-side filter is a
  `whereHas('booking', …)` **never a join**, so an engagement with several booking rows still appears
  once.
- ⚠️ The phone arm of the search only fires **when the term contains digits** — stripping them from
  `"lim"` leaves `''`, and `phone like '%%'` matches everyone.
- **Status chips stay above the drawer**, not inside it: they are the constantly-used filter and they
  carry counts. Those counts are **status-blind** — picking one never zeroes the others — **but
  follow every other filter** (teammate, search, project, bank, dates): pick Ben on the rail and
  Total / New / Booked / … re-count to *his* deals, so a chip's number is exactly how many rows
  clicking it would show. The same "a picker is blind to its own dimension, honours the rest" rule
  the rail already follows; both surfaces read their chips through
  `SalesProjectsController::statusChipCounts()` (`ManageQueryRequest::applyFiltersExcept(…,
  ['status'])`). Until 2026-09-07 they were fully unfiltered, so a teammate's view showed the whole
  team's counts. Pinned by `test_the_booking_list_chip_counts_follow_the_teammate_filter` and
  `test_the_show_page_chip_counts_follow_the_teammate_filter`.
- **A dimension with no options is dropped**, not rendered as an empty heading. The project and bank
  option lists are built from what actually appears on the list.
- Above the rows sit `bookingHeadline` (four cards) and `bookingTeamPerformance` — both computed from
  **one** unpaginated set, so the cards, the scorecard and the rows can never disagree.
- ⚠️ **The Team performance rail IS the teammate filter's picker**, so it is built **assignee-blind**:
  the set is fetched once without that filter, the rail reads it whole, and the headline narrows in
  PHP (`narrowToAssignees()`) while the rows narrow in SQL. A control that dropped every option but
  the one just chosen could not be used a second time. Pinned by
  `SalesProjectsIndexTest::test_the_teammate_filter_narrows_the_rows_but_never_the_rail`.
- ⚠️ `BookingQueryRequest::except()` exists for exactly that, and it **re-applies** the filters —
  the base `QueryRequest` builds its query in the CONSTRUCTOR, so shrinking `$filterable` afterwards
  does nothing on its own.

⚠️⚠️ **A control outside the drawer must call `apply()`, never `applyFilters()`.** This is the
easiest silent bug on any §14 index page, and it caught both the status chips and the teammate rail
here:

| `useResourceIndex` | What it does |
|---|---|
| **`applyFilters()`** | the **drawer's Save** — copies the staged `draft` OVER `filters`, then visits |
| **`apply()`** | *"`filters` is already what I want — go."* (what `clearAll()` uses) |

A chip or a rail card writes straight into `filters`. Calling `applyFilters()` there overwrites that
choice with a `draft` last synced when the drawer opened, so **the visit carries nothing and the
filter appears to do nothing** — no error, the list just comes back unfiltered. Pinned by
[`useResourceIndex.test.js`](/resources/js/composables/useResourceIndex.test.js), whose middle case
exists purely to fail if the two are ever conflated again.

⚠️ **Sorting cannot make a New pipeline appear in the DEFAULT view.** The default scope filters it
out entirely, not merely orders it low — it takes the New chip (or Total) to see it here. The
project/bank drawer option lists are likewise **status-blind**, so an option never vanishes because
of the scope the admin happens to be in.

#### 2. `?view=projects` — the catalogue

The shared `DataTable`: search (name / developer / location), a **Status** filter, header-click sort,
rows-per-page and pagination. **Highlighted projects always sort to the top**; the chosen sort is the
tie-breaker within each group.

Columns: Project · Price · Commission Rate · one count column **per selectable engagement status**
(banded under a PIPELINE header) · Leads. Every column is sortable — the sort whitelist is
`['name', 'price', 'commission_rate', 'pipelines', 'created_at']` **plus one `status_{id}` key per
`Engagement::STATUSES` entry**.

⚠️ **The Price column is not the project's price band** — it is the **booked SPA-price range**
(`min` / `max` of `bookings.spa_price`), which overrides `price_from` / `price_to` in the row
payload and is blank until the project has bookings. The `price` sort accordingly orders by the
lowest booked SPA price, so the sort matches what is displayed.

Per-page enrichment runs three extra queries over **the current page's ids only** — visibility-scoped
stage counts, floor-plan counts, and that price range — so there is no N+1. The `pipelines` sort is a
**correlated subquery** (visibility-scoped `count`), never a join, so the paginator count is never
inflated.

The floor-plan counts come from **`Project::floorPlanCounts()`** (2026-09-14), which counts in
whichever database owns each catalogue project. It replaced a master-only
`CatalogFloorPlan::whereIn(...)` grouped count that returned **0 for every platform-created
project** on a split deployment — a wrong number on screen with nothing failing.

Add / edit / delete happen inline via `ProjectFormModal`; the project-info CSV **Import** lives here
too (see [imports.md](/docs/modules_handbook/manage/engagement/imports.md)).

#### 3. `?view=match` — stage 1, Property Match

The hub's view over `property_match_submissions`. **The submission table, the quiz and its decision
engine belong to [Property Match](/docs/modules_handbook/main/property-match/readMe.md)** — read that
doc for everything about the data itself. What the hub adds:

- ⚠️ **It shows only submissions whose lead is visible**, via `whereHas('lead', LeadVisibility::apply)`.
  A capture with `lead_id IS NULL` (an unverified quiz) has no lead to match, so it is **structurally
  excluded** — which means this stage's badge undercounts what the standalone workbench shows.
- **One query definition, filtered once**: the owner-chip counts and the rows read the same scoped
  base, so a chip can never promise rows the list does not show.
- An **owner filter** whose options are only admins who actually own a submission here — offering the
  whole staff list would be mostly chips that return nothing. A stale uuid in the URL is **ignored**
  rather than 422'd.
- Each row carries **Interested in** (what the buyer picked) beside **Match result** (what the engine
  recommended) — two columns on purpose, since the gap between them is the follow-up call — and
  a **Form answers** button opening the full answer trail, shipped with the page rather than lazily
  fetched.
  ⚠️ **Neither chip carries an icon** (2026-08-11). A heart and a house said nothing their own headings
  had not, and side by side they made two values meant to be COMPARED look like two different kinds of
  thing. Interest keeps its colour.
  - ⚠️ The green **Verified** tick went with them, and *not* for tidiness: **it could never be absent.**
    `PropertyMatchRepository::attachVerifiedResult()` writes `lead_id` and `is_verified` in the same
    statement, and this view lists only submissions that HAVE a lead — so every row here is verified by
    construction. A badge that is always on distinguishes nothing; it only teaches the eye to stop
    reading that column. (On the standalone workbench, which *does* list lead-less captures, it still
    means something.)
- **The submission date moved under the row number** (2026-08-11) — `DataTable`'s `#index` slot, the
  same treatment the [Leads index](/resources/js/Pages/Manage/Leads/Index.vue) uses, so a reader moving
  between the two lists does not have to relearn where the date is. It was briefly a first column
  instead; that spent a full column's width on an already-wide table to show what fits under a number.
  `formatDateCompact()` moved into [`utils/datetime.js`](/resources/js/utils/datetime.js) at the same
  time — two copies of a date format is how two lists meant to look identical stop looking identical.
- The **Property** count column was removed the same day: a bare number with nothing to do on this
  screen, and the person's property activity is on their lead page. (`property_count` still rides in
  the payload — it comes from the shared `BuildsLeadEngagementStats`, which the Leads index also uses.)
- Each row is enriched with the person's history elsewhere, batched over the page's lead ids: call
  count + minutes, Zoom meetings and webinar minutes, WhatsApp in/out counts, active membership
  tiers, and **which project and stage** any existing pipeline sits at (a bare count would send the
  caller to another page to learn the useful half).

##### One row per (person, DAY) — and why the day is not arbitrary (2026-08-11)

A person who retakes the quiz used to produce a row each. Those rows were **identical in every
column except *Interested in* and *Match result*** — the lead, the membership, the pipelines, the
engagement figures are all facts about the PERSON — so it was the same person repeated with two chips
changed.

- Rows are grouped **per (lead, calendar day)**, newest first, and the paginator counts GROUPS.
- ⚠️ **The DAY split is load-bearing, not a compromise.** It stopped being arbitrary the moment the
  ENGAGEMENTS band started counting *from* the submission: two attempts minutes apart share a cutoff
  and therefore the same figures, so merging loses nothing — while attempts months apart have
  genuinely different ones, and merging those would let the older row claim the newer row's activity.
- ⚠️ **The merge is only safe because nothing is dropped.** The row carries **every** attempt of that
  day (`attempts[]`), so *Interested in* and *Match result* render **one chip per distinct answer**.
  Two chips is the row saying *"this person changed their mind between tries"* — the most useful thing
  on the screen, and precisely what a merge-and-show-the-last-one would have deleted.
- **The answer trail is a MODAL, not an expand row** (2026-08-20 —
  [`MatchAnswersModal.vue`](/resources/js/Pages/Manage/SalesProjects/Partials/MatchAnswersModal.vue)).
  An expanded row grew to show a few lines of text and pushed the next four leads off screen, and the
  [VSL funnel roster](/docs/modules_handbook/manage/events/funnels/readMe.md) beside it already reads
  its form answers this way — same gesture, both tables. `DataTable` drops its chevron automatically
  when no `#expand` slot is passed.
- It is one block **per attempt** (time · wants · matched · its own answers). It was a
  flat answer grid, which only worked while a row *was* one submission; merged, "which answers led to
  which recommendation" became unanswerable, and that pairing is the whole reason anyone opens it.
- A `Repeat2 ×N` chip beside the name marks a merged row. It is a **fact, not a warning** — nothing is
  hidden behind it.
- ⚠️ The attempt load re-applies the **owner filter**. Without it a filtered view would list attempts
  that do not match the filter while the row's (filtered) count disagreed with them.

##### The ENGAGEMENTS band counts SINCE the submission, with a toggle (2026-08-11)

The five channel columns default to **what happened on or after this submission** — *"did this quiz
lead anywhere?"* Lifetime totals answer *"who is this person"*, which the Leads index already answers
and which would make a year-old customer look like a hot new lead.

- The **band header is the switch** ([`DataTable`'s new `band-{group}` slot](/resources/js/Components/DataTable.vue)),
  showing `SINCE` / `ALL TIME`. ⚠️ It belongs on the band because it changes what all five mean at
  once; five per-column switches would let a reader compare a since-figure against a lifetime one
  without noticing.
- Both sets ship in every row (`row.x` = since, `row.lifetime.x`), so the flip is pure client state —
  no request, no chance of half the band lagging the other half. ⚠️ Every cell reads through
  `stat(row, key)`; a cell reaching for `row.x` directly would ignore the toggle and nothing about the
  band would look wrong.
- Backed by [`leadEngagementStatsSince()`](/app/Http/Controllers/Concerns/BuildsLeadEngagementStats.php)
  — a **sibling** of `leadEngagementStats()`, never a flag on it: that method's contract is that every
  screen shows the same numbers for the same person, and teaching it to sometimes mean something else
  is how two screens quietly stop agreeing.
  - ⚠️ Keyed by **ROW**, not by lead — one person can hold two rows with two different cutoffs.
  - ⚠️ **One query per channel**, each JOINING the page's per-row cutoffs as a derived table.
    Per-row queries would be six times the page size; a single `where(>= earliest)` would hand every
    row the earliest row's answer.
  - ⚠️ **The window opens at the exact INSTANT of the cutoff, not at its midnight**
    (corrected 2026-08-13; it used to bucket by calendar day and compare whole dates). The rounding
    only ever over-counted, and it counted things that happened *before* the submission it claims to
    measure from — so **Property Match's since-figures dropped slightly** with this change, by a
    slice the page was never entitled to. It also let the "Last ..." stamp print a date EARLIER than
    the cutoff shown beside it. Pinned by `EngagementWindowCutoffTest`.
  - ⚠️ **Property records are NOT date-filtered.** `engagements.created_at` is when the pipeline
    opened, which says nothing about when it reached Booked / Converted / Lost — the states it counts.
- ⚠️ **A BOOKING IS NOT A MEETING** ([`ZoomMeeting::applyHappened()`](/src/Zoom/ZoomMeeting.php),
  2026-08-11). `zoom_meetings.duration` is the length a meeting was **booked** for, written at
  creation — so every figure that summed it over *"not cancelled"* credited a lead with an hour of
  contact for a call scheduled next week. A person nobody had spoken to read as an hour of Zoom time,
  on lists whose entire job is to say who still needs working. The rule is now `status = ENDED` **or**
  `isPast()`, and it lives on the model beside the row-level predicates that define the same thing.
  - ⚠️ Applied in **four** places, because they are four views of one number and a split would show as
    nothing at all: both trait methods, the Leads index's own `withCount` / `withSum` copies, its
    `eng_zoom_meeting_at` (a future booking made *Last Engagement* a date in the future), and
    `LeadQueryRequest::filterMeetingsMin()` — a filter that disagreed with its column would return
    leads whose Zoom cell reads `—`.
  - ⚠️ **ENDED is trusted on its own**, never re-tested against the clock: a meeting booked for an
    hour that Zoom confirmed ended after ten minutes is over, though its booked window has fifty
    minutes to run.
  - The minutes are still the **scheduled** ones — `zoom_meetings` stores no actual duration and has
    no `ended_at`. A meeting that ran long reports what it was booked for. That approximation is
    known; counting a future booking was not.
- Pinned by [`MatchGroupingTest`](/tests/Feature/PropertyMatch/MatchGroupingTest.php): same-day merge,
  every attempt's chips surviving it, the different-day split, and the case the per-row keying exists
  for — one person, two rows, each with its own figures while both report the same lifetime total.

⚠️ That pipeline chip uses **`Engagement::ALL_STATUSES`**, not `STATUSES` — a legacy row on a retired
status still has to render as what it is, not as an em dash.

##### The Appointment column, shared with the VSL roster (2026-08-20)

One column that answers **"is this buyer booked in?"** and, when they are not, is how they
get booked — without leaving the list. Renamed from *Zoom Appointment* on 2026-08-20, when it
stopped being Zoom-only and grew a second consumer.

- **The cell, the modal choreography and the resolver are all shared** with the
  [VSL funnel roster](/docs/modules_handbook/manage/events/funnels/readMe.md):
  [`Components/AppointmentCell.vue`](/resources/js/Components/AppointmentCell.vue),
  [`composables/useLeadAppointment.js`](/resources/js/composables/useLeadAppointment.js) and
  [`Concerns/ResolvesLeadAppointments`](/app/Http/Controllers/Concerns/ResolvesLeadAppointments.php).
  ⚠️ **Cells, not one merged table.** The two lists answer different questions — one campaign's
  roster vs. every quiz-taker ever — and only about six of their ~25 columns overlap. A table
  component carrying both column sets behind flags is the thing nobody can read at 3am.
- ⚠️ **It reads BOTH `zoom_meetings` AND `appointments` now**, soonest first. It used to read Zoom
  alone while its own form modal offered a Type dropdown that could be switched off Zoom — so an
  admin who booked a showroom visit from this very column saw the cell stay empty, with nothing to
  say the write had worked. Pinned by
  [`LeadAppointmentResolverTest`](/tests/Feature/Engagements/LeadAppointmentResolverTest.php).

- **Booked** → the stamp, which opens the **calendar's own** [`EventDetailModal`](/resources/js/Pages/Manage/Calendar/Partials/EventDetailModal.vue):
  Start, **Copy Invitation**, Reschedule, Cancel — the same controls, because it is the same
  component reading the same object.
- **Not booked** → a *Schedule Zoom* button opening the **calendar's own**
  [`AppointmentFormModal`](/resources/js/Pages/Manage/Appointment/Partials/AppointmentFormModal.vue)
  with the lead pre-filled and the new `prefill-type="zoom"`. On success the form closes and the
  detail modal opens on what was just created.
- ⚠️ **`toCalendarArray()` is the payload, not a hand-built array.** `nextAppointmentsForLeads()`
  returns each model's own mapper output so the row and the calendar can never disagree about what a Zoom
  meeting is. The first thing a bespoke payload would drop is **`can_invite`** — which IS the Copy
  Invitation button, and whose absence throws nothing, it just stops being drawn.
- ⚠️ **UPCOMING only, and that is the product rule.** A meeting that finished last month means the
  buyer is *not* booked, so the row offers the button; their history is already two columns away in
  the ENGAGEMENTS band. Cancelled is likewise not booked. Several ahead → the **soonest**.
- ⚠️ Both eager loads (`lead.user.profile`, `agent.user.profile`) are **required by the mapper**, which
  says so in its own docblock — without them this is two extra queries per row on a paginated list.
- ⚠️ The column sits **outside** the ENGAGEMENTS band even though a `Zoom Meeting` column lives in it.
  That one counts meetings that *happened*; this is the next one *booked*, and it is a control rather
  than a figure. Banded together they read as two views of one number.
- **Blocked is shown, disabled, with the reason.** Scheduling needs `manage-calendar` **and** an
  account on the connected Zoom tenant (the meeting is hosted *as* this person). Two flags, not one,
  because they are fixed by two different colleagues — a permission by an admin, a Zoom seat by IT —
  and a silent absence leaves the closer with nobody to ask.
- ⚠️ **How the second modal opens** — the Zoom endpoint answers `back()`, so the new meeting's uuid
  never reaches the browser and there is nothing to hand a modal directly. `AppointmentFormModal`
  therefore emits **`saved`** (added 2026-08-10) *before* it closes itself, and the list watches the
  reloaded rows for that lead's row gaining an `appointment`. The emit exists because
  "the modal closed" and "something was created" are different facts: inferring one from the other
  pops a detail modal at somebody who pressed Cancel.
- Pinned by [`MatchZoomAppointmentTest`](/tests/Feature/PropertyMatch/MatchZoomAppointmentTest.php) —
  past, cancelled, soonest-wins, the payload's `can_invite`, and the props the control needs.

#### 4 & 5. `?view=referrals` and `?view=referral-chain` — stage 3

Both keep the same stage selected because they are two **pivots** of one stage. Written up in
[referrals.md](/docs/modules_handbook/manage/engagement/referrals.md).

#### 6. `GET {uuid}` — the project Show page

`{id}` is the project **uuid**, and the action re-checks `GroupScope::allows()` with a 403.

`PageHeader` (smart back link via `ResolvesBackUrl`) → an identity card → `ShowTabs` with up to
**three** tabs:

| Tab | Shown when | Contains |
|---|---|---|
| **Leads** (default) | always | the headline cards, the Team rail, the status strip, then search + Filters + Export, and the shared engagement table |
| **Payments** | ⚠️ only when the viewer has **`view-sales`** — the `payments` prop is null otherwise | the project's payment rows (`PayablePaymentsTab`) |
| **Details** | always | floor plans, highlights, meta |

⚠️ **The money side is gated separately from the project itself**: an admin who may read projects is
not automatically allowed to see revenue.

**The Leads tab is the booking list's twin, deliberately** (2026-08-11). It runs on the SAME §14
foundation — `useResourceIndex` + `FilterDrawer` + `ActiveFilterChips` — laid out in the same order:
headline cards → Team rail → **status strip** → **search + Filters + Export** → table. It used to
hand-roll its own three-parameter `router.get()`, which is why it had chips and a search box but no
drawer, no removable chips, and no bank or booked-date filter at all.

The one deliberate difference from the cross-project list: **this page's status dimension has no
server default**. A project's own pipeline shows every stage until somebody narrows it, so an empty
`filters.status` simply means Total — there is nothing for `defaulted` to protect.

The **Team performance rail**'s cards filter this list too, and the drawer's Bank options are built
from THIS project's bookings only (an option that can only match nothing is worse than no option).
All of it runs through `projectLeadsQuery()`, so **the export narrows with the screen**.

Above the table sit **four headline cards** from `leadSummary()`, computed over **all** the project's
visible leads (not just the page): **Est. Commission** · **Total Commission** · **Close Rate**
(% of all leads at Booked-or-beyond) · **Conversion Rate** (% of the closed leads that converted).
Their arithmetic deliberately differs from the table's — see
[commission.md](/docs/modules_handbook/manage/engagement/commission.md).

Below them sits the **status chip row**: a Total button followed by every selectable status as its
own independently-filterable chip with its count, visually banded by `STATUS_GROUPS` into
New · Contacting/Appointment Set/With Closer · Booked · Converted · Lost. **That banding is
presentation only** — deliberately not `Engagement::STAGES`, which puts Booked under Closer, reading
oddly on a page where "booked" is the milestone worth grouping around. Counts are **status-blind**,
so picking one never zeroes the others and the groups always add up to the Total chip
(`project.status_total`) — but they follow the teammate / search / bank / date filters, like the
booking list's (2026-09-07). `project.total_pipelines` is the project's TRUE total and never narrows:
it badges the Leads tab and sits on the details card, where a filtered number would read as the
project having fewer leads.

⚠️ **The chip COUNTS fold the retired statuses but the chip FILTER does not.** `statusColumnCounts()`
maps `5 → 4` and `7 → 6` so no lead is dropped from a total, while `leadStatusFilter()` whitelists
against `Engagement::STATUSES` and filters on the exact value. So clicking *With Closer* counts a
legacy *Negotiating* row and then excludes it from the rows. That is existing behaviour, and **the
export copies it exactly** — the file has to have the same number of rows as the screen.

⚠️ `show()` is the one surface that does **not** send `stageCounts` — it mounts `PageHeader` +
`ShowTabs` rather than the hub rail, so there is nothing to badge.

### The two engagement tables ARE one table

[`Components/Sales/EngagementTable.vue`](/resources/js/Components/Sales/EngagementTable.vue) renders
**both** the booking list and the project's Leads tab: the same columns in the same order, the same
sticky Lead + Actions edges, the same `#index` last-touched stamp, the same row actions and the same
row modals — which are the ROW's behaviour, not the page's. Each page keeps only what is genuinely
its own: headline cards, chips, filters, search, export.

Sharing the *cells* was not enough; the two drifted into different sticky settings, different action
sets and different column KEYS for identical content, which is what this collapses. It builds on the
shared cells in `Components/Sales/` — `LeadCell` (contact only, width-capped so a long email
truncates instead of stretching the column), `StatusCell` (status + the receipt chip),
`TeamRolesCell`, `BookingCells`, `CommissionCell`.

**The only structural difference is the Project column** (`show-project`), which the cross-project
list turns on. Three behaviours stay per-surface because they are genuinely different jobs, each a
named prop:

| Prop | On the booking list | On a project's Leads tab | Why |
|---|---|---|---|
| `allow-record-booking` | **off** | **on** | the only rows on the booking list without a booking are LOST ones, and `BookingRepository::create()` advances the engagement to Booked — offering it there would resurrect a dead deal from a list of completed sales |
| `allow-remove` | off | on | removing a lead from a project belongs to that project's own tab |
| `est-help` | off | on | the estimate explainer needs the project's price band |

⚠️ **The Book / Edit action is not "only when a booking exists."** The real rule is
`canManage && (row.booking || allowRecordBooking)`, and the label flips `row.booking ? 'Edit' :
'Book'`. Because the Leads tab passes `allow-record-booking`, **every row there offers Book** —
including a New one. The "only a row that already has a booking gets Edit" rule is true of the
booking list alone.

**Column keys ARE the controllers' sort keys**, and a header is clickable only when its key is in the
`sortable-keys` the page passes — a sortable header with no SQL behind it would navigate and silently
do nothing.

⚠️ **The Leads tab exposes ONE clickable sort, not two.** `LEAD_SORTS` is `['created', 'updated']`
server-side, but `LeadsTable.vue` passes `SORTABLE = ['created']`. `?sort=updated` is honoured by the
server; no header offers it.

**Row actions:** Expand · **Remark / discussion** (the shared
[`Components/LeadDiscussionButton.vue`](/resources/js/Components/LeadDiscussionButton.vue) since
2026-08-12 — ONE component for every lead-shaped table: this one, the Property Match view, and the
VSL funnel roster, so the affordance, tooltip and badge cannot drift apart. Its violet **badge** is
the topic's comment count, batch-computed by the shared `Concerns\BuildsDiscussionCounts` trait —
one grouped query per page, keyed `(lead, topic-title)` so another thread's chatter never inflates
it — so an admin sees a thread has history before spending a click. It opens the shared
`LeadDetailModal` **landed on this PROJECT's topic** in the Discussion tab, via
`openLeadDiscussion(lead_uuid, row.project.name)`: an idempotent `POST …/discussion/topics/ensure`
find-or-creates the topic first, then the modal opens on it — so one lead's remarks about different
deals never interleave. The Property Match view carries the same button with the hard-coded title
`Property Match`. Discussion is the one tab that stays fully writable inside the otherwise read-only
modal, and its axios writes show an inline green "Comment posted." confirmation, since that path
never gets the server's flash toast. This replaced the old eye/"View lead" on 2026-08-11 — the whole
lead is still one click away inside the same modal) · **Book / Edit** (the shared `BookingModal`) ·
**Remove**. The Status cell is an inline `<select>`; most moves fire immediately and
only stop for a modal when they must collect or confirm. Non-`manage-leads` admins see the plain
badge and read-only avatars.

**The Status cell is a COLUMN, always** — one fact per line, in the order they happened: the status
pill, the receipt chip, then the lost-reason chip. Side by side these three compete for one narrow
cell and the free-text reason gets squeezed to a few characters; stacked, each gets the cell's full
width.

The status pill and the reason chip are both **`w-40`**. A `<select>` sizes itself to its LONGEST
option, so the status is already identical on every row — pinning the reason to the same width gives
the column one left edge and one right edge instead of a ragged stack. The reason is free text with
no ceiling, so it `truncate`s into that width (its inner span needs `min-w-0`, or a flex item refuses
to shrink below its content), and its **pencil stays `shrink-0`** — the edit affordance is never what
gets cut off. The whole reason, plus the stage it died in, lives in the `title`.

**A Lost row shows WHY, and lets an admin fix it.** The reason chip is a button →
`LostReasonModal` → `PUT manage.engagements.remark`, which writes `lost_reason` and nothing else.
Read-only (`cursor-default`, disabled) for an admin without `manage-leads`.

⚠️ **Booked and Converted are different colours, and the difference is DEPTH** — `teal` for Booked,
`green` (`bg-green-100 text-green-800`) for Converted. Both are wins so they stay in one family, but
they are not the same news: teal = a unit is held, deep green = the money is in. The end of the funnel
is the darkest thing in the column, so a reader scanning it finds the closes first.

⚠️ **Never copy the colour→class map — spread
[`ENGAGEMENT_TONES`](/resources/js/utils/engagementTones.js).** It used to be copied into EIGHT
components, and a colour missing from a copy fails silently: `badge[c] || badge.slate` goes grey, a
bare `badge[c]` loses its background entirely, and nothing errors. That has bitten twice — once when
a source colour was added, and again on 2026-08-11 when this very recolour greyed out the pipeline
chips on the **Property Match view** and the **event Registrations tab** (both fed by
`pipelinesForLeads()`, which emits `Engagement::ALL_STATUSES` colours) while every other surface
recoloured correctly. All eight now spread the shared map and add only NON-engagement colours after
it; [`engagementTones.test.js`](/resources/js/utils/engagementTones.test.js) fails if a PHP colour
has no class, if a consumer copies instead of spreading, or if a local key shadows a shared one.

⚠️ Do **not** assume every `stage_color` is an engagement stage: the Phone Call and Showroom F2F
lists render a `RecordingStage` of the same name, which is a different vocabulary and must NOT be
wired to this map.

**The receipt chip reads under the STATUS.** A project-fee payment that opened the pipeline shows as
a chip beneath the status pill — it belongs with *where this deal stands*, not with *who this is*. It
is a BUTTON carrying an **eye icon** (without one a chip reads as a label and nobody discovers the
click) that opens the shared `PaymentDetailModal`, the same modal the Payments ledger opens.

- **Its word and colour come from the payment's own state** — `PurchaseHistory::ROW_LABELS` /
  `ROW_COLORS` (GUIDELINES §5). Money that has since gone back reads **Refunded**, never a stale
  *Paid*; the relation is `withTrashed()` and unfiltered by status, so a refunded receipt flips the
  chip rather than making it vanish.
- `ROW_*` are deliberately a second vocabulary rather than a reuse of `STATUSES`. The ledger calls a
  live row *Active* in emerald, which — sitting directly under a status pill that already spends
  its greens on Booked/Converted and rose on Lost — would make a refund look like a lost deal at exactly
  the glance the chip is read at. Hence **Paid/sky** and **Refunded/orange**: money and pipeline, told
  apart on sight.

### Export

**Both** pipeline tables download what is on screen, through the same `ExportsResource` +
`ProjectLeadsExport` pair (§14):

| Route | Rows |
|---|---|
| `GET manage/sales-projects/export` | the CROSS-PROJECT booking list, `bookings-<stamp>.xlsx` |
| `GET manage/sales-projects/{id}/export` | one project's Leads tab, `project-leads-<name>-<stamp>.xlsx` |

⚠️ The cross-project one is a **bare literal segment**, so like `search` it must be declared BEFORE
`GET {id}` or "export" is matched as a project uuid and 404s.

Each rides ONE shared unpaginated builder — `bookingListQuery()` / `projectLeadsQuery()` — which the
page paginates and the export `get()`s, both then running `engagementCard()`. A second hand-written
query is how a file silently stops matching the screen. ⚠️ `per_page` is deliberately never read by
the export: `ExportMenu` forwards the page's whole query string, so honouring it would truncate the
download to one page with nothing to say it had.

`ProjectLeadsExport` takes a **`withProject`** flag. The cross-project file turns it on — without
that column every row is an unattributed unit number — and it reads each row's OWN
`project.commission_rate` rather than one project's rate applied to all of them.

### Sorting

**All relational sorts are correlated subqueries, never joins** — an engagement can have more than
one booking row behind it, and a join would both duplicate rows *and* inflate the paginator count. A
join *inside* a scalar subquery is fine: it cannot multiply the outer result.

`BOOKING_SORTS` covers lead · project · status · unit · spa_price · booking_date · spa_signed ·
lo_signed · bank · commission · created · updated. Two are plain columns because they belong to the
engagement itself: **`created`** (`created_at`) and **`updated`** (`last_activity_at`). Every explicit
sort adds `orderByDesc('engagements.id')` as a stable tie-break, so equal values never shuffle between
pages.

⚠️ **`bank` now sorts through a child table** (`booking_bankers`, 2026-08-12) — the scalar subquery
joins it and takes the newest booking's first banker. A booking with no banker contributes no join
row, so it sorts among the blanks; a booking with three sorts by whichever the join picked, not by
all of them. That is the honest limit of ordering a one-to-many by a single value, and it is why the
CELL renders every banker while the sort reads one.

**Both engagement tables default to MOST RECENTLY TOUCHED** (`last_activity_at desc`, tie-broken by
id), so a deal just added or moved is the first row, and the two lists open the same way. That stamp
shows **under the row number** (the `#index` slot) rather than as its own column — it restates the
order the list is already in, so a column would spend width the row needs for the deal itself.

⚠️ The booking list used to default to the **booking date**, which buried new work twice over: a
freshly opened pipeline has no booking at all, and MySQL sorts those NULLs **last** under `DESC`, so
the newest row landed on the final page. The old order is still one click away as `?sort=booking_date`.
Pinned by `SalesProjectsIndexTest::test_the_booking_list_defaults_to_most_recently_touched_not_booking_date`.

⚠️ **`last_activity_at` is not stamped by a booking-only edit** — see
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md). Editing a unit's price does
not move that deal to the top, despite a code comment here claiming every write touches the stamp.

### Adding a lead to a project

[`AddLeadToProjectModal`](/resources/js/Components/Sales/AddLeadToProjectModal.vue) first offers a
**combo-box search** over existing leads; *"can't find them?"* switches to the create-lead form
(reusing the Leads module's own `LeadForm`). Both post to `manage.sales-projects.leads.store`.
A third opener passes the optional **`lead` prop** (`{ uuid, name }` — added 2026-08-15 for the
VSL roster's add-to-pipeline action): the lead is then LOCKED — search/create disappear and only
the stage question (plus a booking stage's unit fields) remains. Same form, same endpoint.

- An existing pick opens the engagement; a new lead is minted through the shared **`LeadLinker`** —
  the same identity gate and guards as the Leads *New Lead* modal.
- **Opening is idempotent.** `storeLead()` checks whether the engagement already exists **before**
  calling `open()`, because "added" is not always what happened — and the caller may be looking at a
  list this pipeline will not appear on, which makes the flash the only signal of the outcome.
- **It also picks the stage to OPEN AT**, and the two mounts default differently because they are
  doing different jobs: the project page opens at **New** (you are adding someone to work), the
  Bookings list at **Booked** (you are recording a sale that already happened). Choosing a booking
  stage reveals the unit fields and makes `unit_no` + `spa_price` **required** — the status ⇄ booking
  invariant holds on this lane too, via the same `ChangeEngagementStatus` action.
- ⚠️ **The stage is applied ONLY to a pipeline this request actually created.** An existing deal is
  somebody's live work, and an "add" form must never quietly restage it — the flash says it already
  exists, and at what status.
- **The modal is shared, and the project is its only variable.** The Bookings list mounts the SAME
  component as *Add Booking* with no `projectUuid`, so it asks which project first via a project combo
  box (itself `GroupScope`-applied). The picked uuid only builds the POST url — **there is no second
  endpoint, request or controller path**, so the identity gate, the `GroupScope::allows` project check
  and the `LeadVisibility::allows` lead check cannot drift between the two entry points.

⚠️ **`storeLead()`'s `$existing` lookup runs on the non-trashed default scope**, so a soft-deleted
engagement reads as "does not exist": the flash says *added* and the never-restage guard does not
apply. That is correct — a removed pipeline is not live work — and it is safe because `open()`
**resets a restored row to New**, so the request genuinely is starting the deal again. See
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md).

### Project CRUD and its guards

Every single-project action re-checks `GroupScope::allows($user, $project->group_id)` with a 403.

- `store` maps the fields explicitly and stamps `group_id` from the actor. ⚠️ Both `store` and
  `update` **coerce a missing `commission_basis` to Net before the write**, because the column is NOT
  NULL and an explicit null would violate it.
- `update` writes `commission_rate`, `commission_basis` and `vp_at` always, but writes `name` /
  `price_from` / `price_to` **only when the project is not catalogue-linked** — a linked project's
  identity belongs to the catalogue.
  - ⚠️ **`UpdateRequest` therefore stops REQUIRING `name` for a linked project** (2026-09-14).
    It overrides `rules()`, loads the routed project by uuid, and relaxes `name` to
    `['nullable','string','max:180']` when `isCatalogueLinked()`. Without that, validation
    demanded a field the controller was never going to write — so a commission edit failed
    outright whenever `canonicalName()` could not be resolved and the hidden field came back
    empty. A **custom** project still requires its name, and `StoreRequest` is untouched.
- `destroy` **refuses while the project still has engagements**: *"'{name}' still has pipelines —
  close or move them first."* — `{name}` is `displayName()`, not the stale cache column.
- `highlight` toggles the pin.

### `search()` — the typeahead

`GET manage.sales-projects.search`, JSON, `view-projects`. Returns `[]` below two characters, then
active projects only, `GroupScope`-applied, matched with `searchCanonical()` and ordered by canonical
name, capped at 20.

⚠️ **It exists because the marketing `manage.projects.search` is feature-flagged AND only returns
`FocusProject`-wrapped projects**, so it misses the plain sales/custom projects this pipeline uses.
Its consumers include the WhatsApp CTA-link form, which is one of the seven lanes that open an
engagement.

### The cross-project kanban board

`GET manage.sales.pipeline` → `Manage/Sales/Pipeline`, gated on `view-projects` (the board reads
engagements, not sales figures). It lived here as `?view=pipeline` until 2026-07-27; only where it is
mounted changed.

One column per selectable status, each folding its retired twin in (`4` also collects `5`, `6` also
collects `7`) so no legacy lead disappears. Cards are capped at **50 per column** while the header
shows the TRUE total from a separate aggregate, so the limit only bounds the payload.

⚠️ **This is the one place the controller joins on purpose**, for the header aggregates — and the
`projects` join carries its soft-delete guard **inside the join clause**, so an engagement whose
project was deleted still counts in its column and merely stops contributing commission. Filtering is
[`PipelineQueryRequest`](/app/Http/Requests/Manage/SalesProjects/PipelineQueryRequest.php), whose
`project` filter defaults to a `highlighted` sentinel **only when the parameter is absent** — an
explicit blank means "all projects" — and whose empty resolution applies no constraint rather than
emptying the board.

Each card names one **owner**, chosen by `ownerPrecedenceKeys()`: the roll-target role first, then
every other role **late-stage first** — the person closest to the close headlines the card.

### Exporting the Leads tab

`GET manage.sales-projects.export` (`{id}/export`, declared **before** `GET {id}`) →
[`ProjectLeadsExport`](/app/Exports/ProjectLeadsExport.php) through the shared `ExportsResource` trait
(xlsx by default, `?format=csv`, timestamped filename that **includes the project name** — exporting
three projects otherwise leaves three near-identical downloads nobody can tell apart).

**Gated by the group's own `view-projects`** — the file contains exactly what the page already shows,
so demanding more to download it would be theatre. (`view-sales` gates the separate Payments tab,
which this export does not touch.)

**The file must be the same rows the admin is looking at**, and both halves of that break silently:

- **One query, two callers.** `projectLeadsQuery()` returns the builder *unpaginated*; `show()`
  paginates it and `export()` calls `get()` on it, both then running the SAME `engagementCard()`
  mapper — so the commission rule exists once and the file can never quote a different number than
  the screen. **The ordering lives in that shared method too**, so the export writes the rows in the
  order the screen shows them. ⚠️ Both scoping rules — `GroupScope::allows()` and
  `LeadVisibility::apply()` — live there and are not optional; `LEVEL_NONE` is `1 = 0`, so dropping
  it would promote an own-level agent to everything, in a file they keep.
- **Never read `per_page`.** `ExportMenu` forwards the page's whole query string, so honouring the
  table's page size would hand back a 25-row file with nothing to indicate rows were missing. Every
  *filter* is honoured, because they all arrive through the shared `ProjectLeadsQueryRequest` — since
  2026-08-11 that is status, search, bank, booked-date range and teammate, not the three the old
  hand-rolled query knew about.

**The column count is a formula, not a number:** `headings()` is **7 fixed + one column per
`PipelineRole::allCached()` row + 26** = **33 + N**, which is **39 today** with six seeded roles —
**+1 more** when `withProject` is on (the cross-project file). ⚠️ Assert columns **by heading**, never
by position: every index past the role splat moves when a role is added.
⚠️ Resolve a column **by heading, never by a literal index** — every position past the role splat
moves the moment an admin adds a role, which is what `ProjectLeadsExportTest::column()` exists for. The
role columns are a flat splat because the screen shows one mode's role set per row and a file cannot.

**The file carries more than the table**, because a spreadsheet has no colour, tooltip or modal:
`Commission Estimated` as its own Yes/No column (on screen an estimate wears a small amber `EST.`
tag; in a sheet an unconverted forecast would otherwise be summed as earned money), the **commission adjustment and its reason** as their own columns (a sheet shows one number, so a
reader cannot otherwise tell a negotiated figure from a typo), the booking detail
only reachable through the Booking modal (`booking_no`, block, floor, built-up, fee, bank, leader
review, cancellation reason), and the fields the payload already sends but nothing renders (stage,
special remark, lost stage, lead-created, last-activity).

Formatting rules that are not cosmetic: amounts are cast to **float** (the payload hands them over as
strings, which write out as text no spreadsheet will total), dates are **re-parsed** rather than
trusted (⚠️ the payload genuinely mixes `Y-m-d` and full ISO-8601 — `spa_signed_at` is ISO while
`booking_date` and `lo_signed_at` are `Y-m-d`), phone stays **text** (Excel eats a leading `0` and a
`+`), and every customer-typed text column runs through
[`EscapesCsvFormulas`](/app/Exports/Concerns/EscapesCsvFormulas.php) — a lead who types
`=HYPERLINK(...)` into a name or remark is otherwise writing code that runs when an admin opens the
file. The unit **Type** resolves through the project's floor plans keyed by id and passed into the
export, because reading it per booking would be a query a row.

⚠️ **Do not expect the file's Commission column to total the page's Est. Commission card** — see
[commission.md](/docs/modules_handbook/manage/engagement/commission.md). That difference is intended.

Pinned by [`ProjectLeadsExportTest`](/tests/Feature/Engagement/ProjectLeadsExportTest.php).

## Related files

**Backend**
- [app/Http/Controllers/Manage/Engagements/SalesProjectsController.php](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php)
- [src/Property/Project.php](/src/Property/Project.php) — the canonical helpers and the sales columns
- [src/Engagement/Repositories/SalesProjectRepository.php](/src/Engagement/Repositories/SalesProjectRepository.php)
- [app/Http/Requests/Manage/SalesProjects/ProjectQueryRequest.php](/app/Http/Requests/Manage/SalesProjects/ProjectQueryRequest.php) · [BookingQueryRequest.php](/app/Http/Requests/Manage/SalesProjects/BookingQueryRequest.php) · [ProjectLeadsQueryRequest.php](/app/Http/Requests/Manage/SalesProjects/ProjectLeadsQueryRequest.php) · [ReferralQueryRequest.php](/app/Http/Requests/Manage/SalesProjects/ReferralQueryRequest.php) · [PipelineQueryRequest.php](/app/Http/Requests/Manage/SalesProjects/PipelineQueryRequest.php) · [StoreLeadRequest.php](/app/Http/Requests/Manage/SalesProjects/StoreLeadRequest.php)
- [app/Http/Requests/Manage/SalesProjects/StoreRequest.php](/app/Http/Requests/Manage/SalesProjects/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Manage/SalesProjects/UpdateRequest.php) — ⚠️ **not** `Manage/SalesPipeline/Projects/`, which older documentation cites and which does not exist
- [app/Exports/ProjectLeadsExport.php](/app/Exports/ProjectLeadsExport.php) · [app/Exports/Concerns/EscapesCsvFormulas.php](/app/Exports/Concerns/EscapesCsvFormulas.php)
- Controller traits: [ResolvesListQuery](/app/Http/Controllers/Concerns/ResolvesListQuery.php) · [ResolvesBackUrl](/app/Http/Controllers/Concerns/ResolvesBackUrl.php) · [ExportsResource](/app/Http/Controllers/Concerns/ExportsResource.php) · [ResolvesAssignableManagers](/app/Http/Controllers/Concerns/ResolvesAssignableManagers.php) · [BuildsPayablePayments](/app/Http/Controllers/Concerns/BuildsPayablePayments.php) · [SharesClosingConfig](/app/Http/Controllers/Concerns/SharesClosingConfig.php)

**Frontend**
- [resources/js/Pages/Manage/SalesProjects/Index.vue](/resources/js/Pages/Manage/SalesProjects/Index.vue) · [Show.vue](/resources/js/Pages/Manage/SalesProjects/Show.vue)
- Partials: [LeadsTable.vue](/resources/js/Pages/Manage/SalesProjects/Partials/LeadsTable.vue) · [DetailedBookingView.vue](/resources/js/Pages/Manage/SalesProjects/Partials/DetailedBookingView.vue) · [PropertyMatchView.vue](/resources/js/Pages/Manage/SalesProjects/Partials/PropertyMatchView.vue) · [ReferralRepeatView.vue](/resources/js/Pages/Manage/SalesProjects/Partials/ReferralRepeatView.vue) · [ProjectFormModal.vue](/resources/js/Pages/Manage/SalesProjects/Partials/ProjectFormModal.vue) · [ProjectImportModal.vue](/resources/js/Pages/Manage/SalesProjects/Partials/ProjectImportModal.vue) · [BookingImportModal.vue](/resources/js/Pages/Manage/SalesProjects/Partials/BookingImportModal.vue) · [LogReferralModal.vue](/resources/js/Pages/Manage/SalesProjects/Partials/LogReferralModal.vue) · [PipelineBoard.vue](/resources/js/Pages/Manage/SalesProjects/Partials/PipelineBoard.vue) (mounted by `Pages/Manage/Sales/Pipeline.vue`)
- Shared cells: [EngagementTable.vue](/resources/js/Components/Sales/EngagementTable.vue) · [LeadCell.vue](/resources/js/Components/Sales/LeadCell.vue) · [StatusCell.vue](/resources/js/Components/Sales/StatusCell.vue) · [TeamRolesCell.vue](/resources/js/Components/Sales/TeamRolesCell.vue) · [BookingCells.vue](/resources/js/Components/Sales/BookingCells.vue) · [CommissionCell.vue](/resources/js/Components/Sales/CommissionCell.vue) · [TeamPerformanceRail.vue](/resources/js/Components/Sales/TeamPerformanceRail.vue) · [AddLeadToProjectModal.vue](/resources/js/Components/Sales/AddLeadToProjectModal.vue)
- Tab strips: [SalesTabs.vue](/resources/js/Components/SalesTabs.vue) · [HubTabs.vue](/resources/js/Components/HubTabs.vue) · [StageTabs.vue](/resources/js/Components/StageTabs.vue) · [DashboardTabs.vue](/resources/js/Components/DashboardTabs.vue)

**Tests** — [SalesProjectsIndexTest](/tests/Feature/Engagement/SalesProjectsIndexTest.php) · [ProjectLeadsExportTest](/tests/Feature/Engagement/ProjectLeadsExportTest.php) · [SalesPipelineCanonicalTest](/tests/Feature/Engagement/SalesPipelineCanonicalTest.php)

## Related chapters
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) ·
[booking.md](/docs/modules_handbook/manage/engagement/booking.md) ·
[commission.md](/docs/modules_handbook/manage/engagement/commission.md) ·
[referrals.md](/docs/modules_handbook/manage/engagement/referrals.md) ·
[imports.md](/docs/modules_handbook/manage/engagement/imports.md) ·
[perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md)
