# Sales · Renovation

**Nav:** Sales hub (`SalesTabs`) → **Renovation** → three stages: **Enquiries** (the pipeline, `/manage/renovation/enquiries`) → **Jobs** (`/manage/renovation/jobs`) → **Owners** (`/manage/renovation/owners`). `/manage/renovation` redirects to stage 1, query preserved.

## What it does

Runs the renovation product line end to end — the first line other than Property Booking with all
three stages of the standard funnel actually built:

| Stage | Label | What it is |
|---|---|---|
| Pipeline | **Enquiries** | `renovation_jobs` rows being priced and approved — Draft → Pending Review → Approved Quotation → Quotation Sent. Added by hand, or pulled in from the Concierge desk. |
| Sales | **Jobs** | The same rows once the closer marks one **Closed** — Awarded, built and handed over (plus Lost / Cancelled, a filter away). |
| Referral & Repeat | **Owners** | Owners with a completed job, with the jobs they GAVE (repeat) and the jobs they SENT (referral). |

A won job then carries a **delivery** half — where the site is, what has been collected, when it was
handed over, and the photos/video of both (see *After it is won* below).

Until 2026-09-07 the whole line was a placeholder page with three "Soon" stages
(`Manage\Sales\ProductLinesController`). It graduated; Management and Customized AI are still there.

## How it works

**Stage 1 and stage 2 are ONE table, split by status** (2026-09-10 — until then stage 1 was a read
of the Concierge desk and a job started in "Quoting"). A row is added to the pipeline with the *same
form* as a job, because it is the same record; "moving to Jobs" is a status change, not a copy.

**The pipeline is a two-person approval.** Each move is its own repository method with its own
guard and stamp — never a generic "next" button:

| Step | Who | What it does |
|---|---|---|
| **Add to pipeline** (`store`) | anyone with `manage-sales` | Saves a **Draft**. The form's *Created by* (default: you) is **whose commission it is** — the person who brought the enquiry in; it may be a colleague when keyed in on their behalf. The Money and Contractor sections are hidden unless the viewer may price (below). |
| **Submit** (`submit`) | the creator, or anyone with `manage-sales` | Draft → **Pending Review**. Refused without an owner — the quotation is addressed to somebody. |
| **Review** (`review`) | `review-renovation` (Boon) | Keys in **Money + Contractor** and stamps `reviewed_at/by`. Status stays Pending Review; the stamp is what tells the approver it is theirs. A contract value is required. Re-review overwrites. |
| **Approve** (`approve`) | `approve-renovation` (Zen) | → **Approved Quotation**, names the **closer** (`assigned_admin_id`, default = the reviewer). **The reviewer may not approve their own review** — the repository refuses it even for a super admin holding both flags; the page hides the button and offers *Send back* instead. |
| **Send back** (`sendBack`) | `approve-renovation` | → Draft with a required note (`approval_note`); the keyed-in price is kept, the submit/review stamps are cleared. |
| **Closer status** (`setCloserStatus`) | the assigned closer, or an approver | `RenovationJob::CLOSER_STATUSES`: **No action** (= Approved Quotation) ⇄ **Quotation sent** → **Closed**. *Closed* IS the move to stage 2: status **Awarded**, `awarded_at` stamped, the row leaves the pipeline. |

- **Who may price.** `PresentsRenovation::canPrice()`: before approval a reviewer or an approver; once
  approved, only an approver (the number has been signed off). The form hides the sections for
  everyone else AND `JobsController::mapInput()` drops those keys — a hidden field is not a guard.
- **Abilities are decided on the server, once.** `renovationAbilities()` returns the `can` map every
  row carries (`submit / review / approve / send_back / closer / edit / price / advance / …`); the
  pipeline list (`PipelineActions.vue`), the Show page and the modals draw only what it allows. A
  button that would 403 is never drawn.
- **Two permissions**, seeded by `RolesSeeder` (a deploy does not run it — `php artisan db:seed
  --class="\RolesSeeder" --force`): `review-renovation` and `approve-renovation`, on Manage → Roles
  under *Renovation Quotations*. A super admin holds both only because a super admin holds
  everything; the two-person rule is what actually separates the seats.
- **The generic `advance()` refuses a pipeline row** — the won half (Awarded → In progress →
  Completed) is all `NEXT_STATUS` covers now.
- **The Concierge desk still feeds the pipeline.** A member ticking *Renovation* on the concierge form
  lands on the desk; the pipeline page lists those not yet pulled in (up to eight, with a count and a
  link to the desk) and **Add to pipeline** (`POST jobs/from-enquiry/{id}`) copies the unit and the
  owner across as a Draft, keeping `concierge_request_id`. A second row for the same enquiry is
  refused. The desk agent is NOT copied into the closer seat — that is named at approval.
- **Status ladder renumbered** (`2026_09_10_000001`): 1 Draft · 2 Pending Review · 3 Approved
  Quotation · 4 Quotation Sent · 5 Awarded · 6 In progress · 7 Completed · 8 Lost · 9 Cancelled. The
  migration remaps any old rows top-down; `STATUS_QUOTING` survives as a deprecated alias of Draft
  because the 2026-09-07 create migration reads it for its column default (GUIDELINES §7).

**Stage 2 is the won half of the same table.** A row arrives when the closer marks it Closed; there
is deliberately no "New job" button here — every job starts in the pipeline, where it is priced and
approved.

- Lifecycle `Awarded → In progress → Completed`, plus `Lost` (reason required) and `Cancelled` from
  any live state (both also reachable from the pipeline). The won order lives in **one** place,
  `RenovationJob::NEXT_STATUS`, and the Show page prints the next state the server sent rather than
  naming it itself. Each state stamps its own timestamp via `RenovationJob::STATUS_TIMESTAMPS`.
- No controller writes a status: the repository's pipeline methods and `advance() / lose() / cancel()`
  do, because each is a state change that also stamps a date — the thing that earns a method of its
  own (GUIDELINES §2). Plain field edits all go through the single `update()`.
- **Money is carried on both sides.** `contract_value` is what the owner pays, `cost_value` what the
  work costs us, and `commission_amount` / `commission_pct` what this business keeps. That covers
  both ways of running the line without a schema change — self-performed (commission = the margin)
  and brokered (commission = the cut of a contractor's price) — and is the same shape
  `concierge_requests` already uses for a closed sale.
- **`renovation_job_milestones` is how a job is actually paid.** A renovation is not settled once at
  the end, so the contract value alone cannot say how much has been collected. "Collected" on the
  Jobs list totals exactly the claims marked paid, and the Claims tab flags a schedule that does not
  add up to the contract.

### After it is won — the delivery layer

**`status` is the sale; `work_stage` is the site.** Two axes, deliberately not merged. A job sits
*Awarded* for weeks before anybody swings a hammer, and the funnel counts read `status` — folding the
trades into it would both lie about the site and break the Owners stage. The trade sequence lives in
`RenovationJob::WORK_STAGES`: Site possession → Hacking → Plumbing & wiring → Masonry & tiling →
Carpentry → Ceiling & painting → Installation → Cleaning → Handed over.

- **The site moves the sale, not the other way round.** `setWorkStage()` on an *Awarded* job flips it
  to *In progress* and stamps `started_at`. Asking an admin to remember a second button for that is
  how a pipeline drifts away from the site.
- **`renovation_job_progress` is a diary, not a status column.** One row per stage entry, so the rail
  prints the date beside every stage the job passed. Without it "we are in carpentry" cannot say
  *since when*, and a job stuck three weeks in wiring looks identical to one that flew through —
  which is exactly the question the desk gets asked. Deleting an entry does **not** walk the job's
  current stage backwards; it is a record of what was reported.
- **Handover closes all three axes in one action** (`handOver()`): `handed_over_at`, `work_stage =
  Handed over`, and the sale `Completed`. Leaving the sale open after a handover is what puts a
  finished job in the "in progress" count and keeps its owner out of Referral & Repeat. It is not one
  more stage button, and `SetStageRequest` refuses the Handover stage for that reason.
- **`target_completion` is what the owner was told**; `days_late` counts past it and goes quiet once
  the unit is handed over.

**The money question a won job is actually asked.** `JobsController::money()` returns contract /
scheduled / collected / balance / deposit, and the identity card prints it above a bar.

- **Collected comes off the CLAIMS, never off the job.** The job knows what it agreed to; only a
  claim marked paid knows what arrived. The list gets the same numbers from
  `withSum(['milestones as collected_sum' => paid], 'amount')` — one definition, two callers.
- **Balance is against the CONTRACT, not the schedule.** An incomplete claim schedule must not make
  the outstanding amount look smaller than it is; when the two disagree the card says so out loud.
- **Deposit** is simply the first claim in the schedule — the number the desk is asked for first.

**Photos and video go through `MediaService`**, never a disk write of this module's own — see the
[Media handbook](/docs/modules_handbook/shared/media/readMe.md). Two collections on the job's
`mediable` morph: `renovation_progress` (tagged in `meta.work_stage` with the stage the site was at,
or forty site photos are just forty site photos) and `renovation_handover` (the final walkthrough).
Files upload the moment they are chosen — this panel is not inside a form, and a site photo taken on
a phone is dropped in one action.

- ⚠️ **The bucket is private, so `url` is a SHORT-LIVED signed URL** re-minted on every page load.
  Nothing may cache, bookmark or forward it.
- ⚠️ **`JobsController::destroy()` deletes the files before the rows, outside the transaction, and
  logs-and-continues.** `Media` has no `deleting` hook: drop the rows first and the objects live in
  GCS forever with nothing left that could name them.
- The upload ceiling (96 MB) is set under this box's `upload_max_filesize` (100 MB) on purpose — a
  rule that lets through more than PHP accepts produces an empty `$_FILES` and a confusing
  "required" error instead of an honest "too big".

**Stage 3 counts, it does not guess.** An owner appears once one of their jobs is `Completed`. *Jobs
given* is a grouped count of their completed jobs; *jobs sent* counts jobs whose
`referred_by_lead_id` is them — which is why that field is on the job form with a note saying so.
Nothing else on the page can produce a referral number.

**The rail is on every page**, so all three controllers send `stageCounts`
(`PresentsRenovation::renovationStageCounts()`) — one definition, so a badge can never disagree with
the list it opens. Reads need `view-sales`; every write needs `manage-sales`. No new permission was
added, so nothing here waits on a `RolesSeeder` run.

## Known gaps

- **No contractor quote comparison.** A job holds ONE contractor and one cost; comparing three quotes
  line by line (what the member-facing waitlist copy promises) would be a `renovation_job_quotes`
  child table. Deliberately not built yet.
- **No export.** Add one via the [Export pattern](/docs/modules_handbook/shared/export/readMe.md) when
  somebody needs the file; `FromCollection` is right while the list is small.
- **No member-facing surface.** The portal still shows Renovation as a waitlist feature on Landlord
  Management while the concierge form already accepts it — two doors, one open, one "soon". That
  contradiction is in the portal, not here. The handover gallery is the obvious first thing to show
  an owner once there is one.
- **Nothing notifies the owner.** A stage change and a handover are both moments a customer would
  want to hear about; neither sends anything today (`SmsSender` / WhatsApp / `EmailSender` are all
  available).
- **No defect-liability tracking.** A handover note can say "3 months", but nothing counts it down or
  holds a snag list.
- **Stage 3 pages in PHP.** A grouped query cannot be paginated by the database (the paginator counts
  groups as rows), so `OwnersController` sorts and pages the collection. Fine at this size; revisit if
  completed renovation owners ever run to thousands.

## Related files

**Backend**
- `src/Renovation/RenovationJob.php` — statuses, `NEXT_STATUS`, `STATUS_TIMESTAMPS`, `generateRef()` (`RJ-2026-001`).
- `src/Renovation/RenovationJobMilestone.php` — one progressive claim (child, no uuid).
- `src/Renovation/RenovationJobProgress.php` — one site-diary entry (child, no uuid).
- `src/Renovation/Repositories/RenovationJobRepository.php` — every write, each in `DB::transaction`.
- `app/Http/Controllers/Manage/Renovation/EnquiriesController.php` — stage 1 (the pipeline list + the desk panel).
- `app/Http/Controllers/Manage/Renovation/JobsController.php` — stage 2 list, the Show page, and EVERY write: submit / review / approve / sendBack / setCloserStatus, advance / lose / cancel, milestones, site, media, the desk bridge.
- `app/Http/Controllers/Manage/Renovation/OwnersController.php` — stage 3.
- `app/Http/Controllers/Concerns/PresentsRenovation.php` — stage scopes and counts, the shared row shape (`renovationRow`), `canPrice`, `renovationAbilities`.
- `app/Http/Requests/Manage/Renovation/JobQueryRequest.php` (both lists)
- `app/Http/Requests/Manage/Renovation/Jobs/{StoreRequest,UpdateRequest,ReviewRequest,ApproveRequest,SendBackRequest,CloserStatusRequest,LoseRequest,MilestoneRequest,SetStageRequest,HandoverRequest,MediaRequest}.php`
- `src/Auth/Permission.php` — `REVIEW_RENOVATION`, `APPROVE_RENOVATION`.

**Frontend**
- `resources/js/Pages/Manage/Renovation/Enquiries.vue` — the pipeline: desk panel, list, row actions, the three modals.
- `resources/js/Pages/Manage/Renovation/Jobs/Index.vue`, `Show.vue`
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/JobForm.vue` (`sections` prop; *Created by*), `JobFormModal.vue`
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/ReviewModal.vue` — Money + Contractor, "Save & send for approval".
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/ApproveModal.vue` — the priced summary, closer picker, Approve / Send back.
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/CloserStatusControl.vue` — No action / Quotation sent / Closed (confirms Closed).
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/PipelineActions.vue` — the row's next step for THIS viewer, off `job.can`.
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/WorkflowCard.vue` — the step rail with who/when.
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/Tabs/MilestonesTab.vue` — the claims schedule.
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/Tabs/SiteTab.vue` — the stage rail, diary, site photos and handover.
- `resources/js/Pages/Manage/Renovation/Jobs/Partials/JobMediaPanel.vue` — upload/preview/delete for one collection.
- `resources/js/Pages/Manage/Renovation/Owners.vue`
- `resources/js/Components/SalesTabs.vue` — the stage rail (renovation's three stages are real).

**Migrations**
- `database/migrations/2026_09_07_000001_create_renovation_jobs_table.php`
- `database/migrations/2026_09_07_000002_create_renovation_job_milestones_table.php`
- `database/migrations/2026_09_07_000003_add_delivery_to_renovation_jobs_table.php`
- `database/migrations/2026_09_07_000004_create_renovation_job_progress_table.php`
- `database/migrations/2026_09_10_000001_add_pipeline_workflow_to_renovation_jobs_table.php` — the submit / review / approve stamps + the status remap.

**Tests** — `tests/Feature/Manage/Renovation/PipelineWorkflowTest.php`.

**Routes** — `routes/web.php`, `manage.renovation.*` (`enquiries.index`, `jobs.*` incl. `jobs.submit / review / approve / send-back / closer-status`, `owners.index`).

**Related docs** — [Property Concierge](/docs/modules_handbook/manage/portal-engagement/readMe.md) owns
the desk a member's renovation request lands on before it is pulled into the pipeline; the
[Sales hub](/docs/modules_handbook/manage/engagement/sales-projects.md) owns the strip this line hangs
off; [Rental Estimate](/docs/modules_handbook/main/rental-estimate/readMe.md) is the sibling line whose
board is a direct status update with no approval — the two were reshaped on the same day.
