# Appointment Engine — surfaces

**Module:** [AI Appointment System](/docs/modules_handbook/manage/appointment-engine/readMe.md) · **Route prefix:** `/manage/appointment-engine` · **Route names:** `manage.appointment-engine.*` · **Middleware:** `auth` → `admin` → `permission:view-appointment-engine` → `ae.agency`

Every route, controller action and screen the module exposes — what a person can actually do, and which props feed each page. Several 2026-09-07/08 reforms deleted pages and left redirects behind; this doc describes what exists **today**.

> Companion docs: [runner.md](/docs/modules_handbook/manage/appointment-engine/runner.md) for the engine behind these screens; [data-model.md](/docs/modules_handbook/manage/appointment-engine/data-model.md) for the rows they read.

The AE ("AI Appointment System") is a self-contained Manage suite mounted at `/manage/appointment-engine`. It owns 72 routes, 11 controllers, 3 request classes and 15 Vue pages. This section maps what a user can actually *do*: every URL, what it renders, what feeds it, and which URLs are now only redirects to pages that were deleted in the 2026-09-07/08 reforms.

### 1. How the suite is entered

The whole group is inside a feature flag and behind a four-layer middleware chain (`routes/web.php:1955-1958`):

```php
if (config('features.appointment_engine_enabled')) {          // config/features.php:31
    Route::prefix('manage/appointment-engine')
        ->name('manage.appointment-engine.')
        ->middleware(['auth', 'admin', 'permission:' . Permission::VIEW_APPOINTMENT_ENGINE, 'ae.agency'])
```

| Layer | Class | Effect |
|---|---|---|
| `web` | — | session, CSRF |
| `auth` | `Illuminate\Auth\Middleware\Authenticate` | signed in |
| `admin` | `App\Http\Middleware\EnsureUserIsAdmin` | admin-side user |
| `permission:view-appointment-engine` | spatie | `Permission::VIEW_APPOINTMENT_ENGINE = 'view-appointment-engine'` (`src/Auth/Permission.php:189`) |
| `ae.agency` | `App\Http\Middleware\EnsureAeAgencyChosen` (`app/Http/Kernel.php:55`) | agency-first gate, below |

`config('features.appointment_engine_enabled')` reads `FEATURE_APPOINTMENT_ENGINE_ENABLED`, **default `true`** (`config/features.php:31`). The group sits at the top level of `routes/web.php`, deliberately *not* nested in the `features.projects_enabled` block — the Inertia share gates the Hub card and sidebar entry on `appointment_engine` alone, so nesting made every link 404 on an install with `FEATURE_PROJECTS_ENABLED=false` (`routes/web.php:1943-1954`).

**The agency gate.** `EnsureAeAgencyChosen::handle()` bounces platform staff to the agency chooser before anything else renders. Order matters:

1. Four route-name patterns are exempt and pass straight through — `manage.appointment-engine.agency.*`, `…leads.flow`, `…leads.stop-*`, `…leads.chat`. Those four are reached from the **Messages inbox**, outside the suite, and are already scoped by the lead (`app/Http/Middleware/EnsureAeAgencyChosen.php:29-38`).
2. Otherwise, if `AeScope::needsChoice($user) && Group::query()->exists()`: a JSON request `abort(409, 'Choose the agency you are working in first.')`, everything else `redirect()->route('manage.appointment-engine.agency.index')` (`:40-47`). An install with zero groups has nothing to choose and is never blocked.

`AeScope` (`src/AppointmentEngine/Support/AeScope.php`) is the single source of truth for "which agency am I in": `SESSION_KEY = 'ae.agency_id'`; `groupId()` returns the user's own `groupId()` first, else the session choice, else `null` (all agencies); `needsChoice()` is true only for a user with no group who has never written the session key. Every AE read/write scopes through `AeScope::apply()` (strict `where group_id = X`) or `AeScope::applyShared()` (platform rows with `group_id IS NULL` **plus** the acting agency's) — never through `GroupScope` directly.

**Permissions beyond the group middleware.** Only five checks exist inside the suite:

| Where | Check | Failure |
|---|---|---|
| `HandoffsController::handoffFor()` | `$user->id === $handoff->admin_id \|\| $user->can('manage-appointments')` | `abort(404)` — whether an offer exists is not a stranger's to learn (`HandoffsController.php:83`) |
| `ProjectsController::updateClosers()` | `$viewer->can(Permission::MANAGE_APPOINTMENTS)`, then `AeScope::allows($viewer, $groupId)` | `abort(403)` twice (`ProjectsController.php:375, 386`) |
| `ProjectsController::applyBusinessFields()` | `$viewer->can(Permission::MANAGE_PROJECTS)` | flash warning, name still saved (`ProjectsController.php:531`) |
| `ProjectsController::sales()` | `sharedProjects()` lookup (shared + own agency, like `show()`; 404 otherwise), then `$viewer->can(Permission::VIEW_PROJECTS)` | flash + redirect back to the project |
| `LeadsController::scoped()` | a `sales-agent` without `manage-appointment-engine` is narrowed to `ae_leads.assigned_admin_id = $viewer->id` | rows simply do not exist (`LeadsController.php:503`) |

Note the consequence: creating, syncing, test-calling and deleting **AI call profiles inside this suite needs only `view-appointment-engine`** — neither `AiProfilesController` (AE) nor its host parent contains an `abort_unless`/`can()` check.

### 2. Navigation — the sidebar and the three tab strips

The suite's sidebar (`resources/js/Layouts/ManageLayout.vue:207-236`) is `appointmentEngineNav`, selected when the resolved suite is `appointment` (`:461`):

| Zone | Entry | Href | `prefixes` | Permission |
|---|---|---|---|---|
| top | Dashboard | `/manage/appointment-engine/dashboard` | `…/dashboard`, `…/coverage` | `view-appointment-engine` |
| top | Projects | `…/projects` | `…/projects`, `…/ai-agent`, `…/automation`, `…/content`, `…/ingest` | `view-appointment-engine` |
| top | Leads | `…/leads` | — | `view-appointment-engine` |
| top | Appointments | `…/appointments` | `…/appointments`, `…/showroom` | `view-appointment-engine` |
| `Channel` section | Messages / Zoom / Phone Call / Showroom F2F | host modules with `?suite=appointment` | `/manage/messages`, `/manage/zoom`, `/manage/calls`, `/manage/f2f` | `view-whatsapp` / `view-zoom` / `view-calls` / `view-f2f` |
| `Team` section | Admins | `/manage/people/admins?suite=appointment` | `/manage/people/admins`, `/manage/people/roles` | `permissionAny: view-admins, view-roles` |
| pinned footer | Setting | `/manage/messages/channels?suite=appointment` | *(none, by design)* | `manage-appointment-engine` (`ManageLayout.vue:246-249`) |

**AI Agent has no sidebar entry.** Its paths light the *Projects* entry instead; the section is reached by its own strip or by direct URL.

Three strips exist:

- **`Pages/Manage/AppointmentEngine/Partials/SuiteTabs.vue`** — one surviving section, `appointments`: *Appointments* (`…/appointments/list`) + *Overview* (`…/appointments/overview`) (`SuiteTabs.vue:26-29`). The `dashboard`, `leads`, `settings` and `Conversations` sections were all deleted; the file's comments record each death.
- **`Pages/Manage/AppointmentEngine/Partials/AiAgentTabs.vue`** — *Workflows* (`…/ai-agent`) · *AI Profiles* (`…/ai-agent/profiles`) · *Knowledge* (`…/ai-agent/knowledge`) (`AiAgentTabs.vue:14-18`). `/ingest` deliberately resolves to the **Knowledge** tab (`:26-30`).
- **`Components/AppointmentEngine/AeSettingTabs.vue`** and **`AeTeamTabs.vue`** — mounted by *host* pages while the resolved suite is `appointment`. Setting is seven host tabs (WhatsApp → Channels/Tags/General, Delivery APIs, Zoom, Meta Account, Devices, Closing Mode, Notifications); Team is three (Admins, Roles, Groups). Neither has a backing AE controller — the suite owns no settings or people pages at all since 2026-09-07.

### 3. Route table

All 72 routes; the `/manage/appointment-engine` prefix and the `manage.appointment-engine.` name prefix are omitted from the columns. Every route carries the group middleware from §1; the **Extra** column lists anything on top.

#### Agency

| Method | URI | Action | Name | What it does | Extra |
|---|---|---|---|---|---|
| GET | `agency` | `AgencyController@index` | `agency.index` | Agency chooser; a user *with* a group is redirected to the dashboard | exempt from `ae.agency` |
| POST | `agency` | `AgencyController@select` | `agency.select` | `AeScope::choose()`, flash, redirect to the dashboard | exempt |

#### Dashboard

| Method | URI | Action | Name | What it does | Extra |
|---|---|---|---|---|---|
| GET | `/` | closure | `manage.appointment-engine.` | 302 → `/manage/appointment-engine/dashboard` (`routes/web.php:1967`) | |
| GET | `dashboard` | `DashboardController@__invoke` | `dashboard` | The leader's home | |
| POST | `dashboard/chat` | `DashboardController@chat` | `dashboard.chat` | One turn of the performance-analyst chat, JSON | `DashboardChatRequest` |

#### Projects

| Method | URI | Action | Name | What it does | Extra |
|---|---|---|---|---|---|
| GET | `projects` | `ProjectsController@index` | `projects.index` | The hub — one row per deal, funnel + setup state | |
| POST | `projects` | `@store` | `projects.store` | Create a deal, bridge to the CRM sales project, redirect to its Show page | `manage-projects` needed for the money fields |
| GET | `projects/{id}` | `@show` | `projects.show` | One deal: band + Leads / Appointments / Workflow tabs | |
| PUT | `projects/{id}` | `@update` | `projects.update` | Rename / archive + business fields | catalogue-linked names are refused |
| PUT | `projects/{id}/sheet` | `@updateSheet` | `projects.sheet` | Point the deal's **sheet** flow at a Google Sheet and sync once, in-request | |
| PUT | `projects/{id}/closers` | `@updateClosers` | `projects.closers` | Replace ONE agency's closer list for this project | `manage-appointments`; `AeScope::allows` |
| GET | `projects/{id}/sales` | `@sales` | `projects.sales` | Delegates to the CRM Sales Project page at this URL | `view-projects` |
| DELETE | `projects/{id}` | `@destroy` | `projects.destroy` | Delete — refused if the deal has any lead or workflow | |

#### Hand-offs (tapped from Telegram)

| Method | URI | Action | Name | What it does | Extra |
|---|---|---|---|---|---|
| GET | `handoffs/{id}/accept` | `HandoffsController@accept` | `handoffs.accept` | Take the appointment; both idempotent, both land on the lead | own offer or `manage-appointments`, else 404 |
| GET | `handoffs/{id}/pass` | `HandoffsController@pass` | `handoffs.pass` | `CloserRotation::advance()` to the next closer | same |

#### Leads

| Method | URI | Action | Name | What it does | Extra |
|---|---|---|---|---|---|
| GET | `leads` | `LeadsController@index` | `leads.index` | The lead book | `LeadQueryRequest` |
| POST | `leads` | `@store` | `leads.store` | Add one by hand; an existing phone joins that lead instead of making a second | |
| POST | `leads/import` | `@import` | `leads.import` | CSV/XLSX import, headings matched by name | declared **before** `{id}` |
| GET | `leads/{id}/chat` | `@chat` | `leads.chat` | Resolve the WhatsApp conversation, redirect into the inbox | exempt from `ae.agency` |
| GET | `leads/{id}/flow` | `@flow` | `leads.flow` | JSON: the plan + this lead's position (the inbox's "View the flow") | exempt |
| POST | `leads/{id}/stop-sequence` | `@stopSequence` | `leads.stop-sequence` | Stop scheduled messages + calls; AI chat keeps answering | exempt |
| POST | `leads/{id}/stop-chat` | `@stopChat` | `leads.stop-chat` | Stop AI replies; the sequence continues | exempt |
| POST | `leads/{id}/stop-automation` | `@stopAutomation` | `leads.stop-automation` | Stop everything, terminally | exempt |
| GET | `leads/{id}` | `@show` | `leads.show` | The lead page (delegated or native — §4.7) | |
| PUT | `leads/{id}` | `@update` | `leads.update` | Edit; a phone clash inside the group is refused | |
| DELETE | `leads/{id}` | `@destroy` | `leads.destroy` | `LeadEraser::erase()` — runs, calls, hand-offs, appointments, chat takeover | |

#### AI Agent — workflows and knowledge

| Method | URI | Action | Name | What it does | Extra |
|---|---|---|---|---|---|
| GET | `ai-agent` | `WorkflowsController@index` | `ai-agent.index` | Every workflow this agency has | |
| POST | `ai-agent/workflows` | `@store` | `ai-agent.workflows.store` | Create from an ENTRY POINT with a one-step default plan | `entry ∈ Plan::ENTRIES` else 422 |
| GET | `ai-agent/workflows/{id}` | `@show` | `ai-agent.workflows.show` | **Redirect only** → the project's Workflow tab | |
| PUT | `ai-agent/workflows/{id}` | `@update` | `ai-agent.workflows.update` | Name / description / project | |
| PUT | `ai-agent/workflows/{id}/plan` | `@savePlan` | `ai-agent.workflows.plan` | ONE write for the whole plan, then `PlanCompiler::compile()` | |
| POST | `ai-agent/workflows/{id}/publish` | `@publish` | `ai-agent.workflows.publish` | Toggle live; `problems()` gate on the way ON only | |
| POST | `ai-agent/workflows/{id}/sheet-check` | `@checkSheet` | `ai-agent.workflows.sheet-check` | "Check now" — one forced `Triggers::syncNode()`, JSON | 422 if the flow is not sheet-triggered |
| DELETE | `ai-agent/workflows/{id}` | `@destroy` | `ai-agent.workflows.destroy` | Delete; `?stay=1` returns to the project's Workflow tab | |
| GET | `ai-agent/knowledge` | `ContentController@library` | `ai-agent.knowledge` | The content library, grouped project → category | |
| POST | `ai-agent/knowledge/{id}/live` | `@toggleLive` | `ai-agent.knowledge.live` | Draft ⇄ live | |
| DELETE | `ai-agent/knowledge/{id}` | `@destroyItem` | `ai-agent.knowledge.destroy` | Delete a content item | |

#### AI Agent — AI call profiles

The two **pages** live under `ai-agent/`; every **write/XHR** endpoint kept its old `calls/ai-profiles/*` path because the Vue partials post to them by literal URL and a POST cannot be redirected losslessly (`routes/web.php:2018-2023`).

| Method | URI | Action | Name | What it does |
|---|---|---|---|---|
| GET | `ai-agent/profiles` | `AiProfilesController@index` | `ai-agent.profiles.index` | Profile list |
| GET | `ai-agent/profiles/{id}` | `@show` | `ai-agent.profiles.show` | One profile: Settings / Copilot / Test Lab / Calls |
| GET | `calls/ai-profiles` | closure | `calls.ai-profiles.index` | **302** → `ai-agent/profiles` |
| GET | `calls/ai-profiles/{id}` | closure | `calls.ai-profiles.show` | **302** → `ai-agent/profiles/{id}` |
| GET | `calls/ai-profiles/importable` | `@importable` | `…importable` | Retell agents no local profile links yet (JSON) |
| POST | `calls/ai-profiles/import` | `@import` | `…import` | Adopt a Retell agent as a local profile |
| POST | `calls/ai-profiles/clone-voice` | `@cloneVoice` | `…clone-voice` | Re-encode a sample to mono MP3 44.1 kHz for cloning |
| POST | `calls/ai-profiles` | `@store` | `…store` | Create (stamped with the agency) |
| PUT | `calls/ai-profiles/{id}` | `@update` | `…update` | Edit |
| DELETE | `calls/ai-profiles/{id}` | `@destroy` | `…destroy` | Soft-delete + provider cleanup |
| POST | `calls/ai-profiles/{id}/sync` | `@sync` | `…sync` | Push to Retell (create or version+publish) |
| POST | `calls/ai-profiles/{id}/pull` | `@pull` | `…pull` | Pull the provider's published state back |
| POST | `calls/ai-profiles/{id}/toggle` | `@toggle` | `…toggle` | Pause / resume for callers |
| POST | `calls/ai-profiles/{id}/test-call` | `@testCall` | `…test-call` | A **real billed** call — `throttle:5,10` |
| POST | `calls/ai-profiles/{id}/chat` | `@chatStart` | `…chat.start` | Start a text Test-LLM session (JSON) |
| POST | `calls/ai-profiles/{id}/chat/send` | `@chatSend` | `…chat.send` | One message into that session |
| POST | `calls/ai-profiles/{id}/web-call` | `@webCallStart` | `…web-call` | Browser Test Audio over WebRTC — `throttle:10,10`, Retell still bills |
| GET | `calls/ai-profiles/{id}/test-runs` | `@testRuns` | `…test-runs` | Kept test runs, lazily as JSON |
| POST | `calls/ai-profiles/{id}/copilot` | `@copilot` | `…copilot` | One prompt-copilot turn (company AI key, not Retell) |
| POST | `calls/ai-profiles/{id}/copilot/apply` | `@copilotApply` | `…copilot.apply` | Apply a proposal, optionally sync |

#### Appointments

| Method | URI | Action | Name | What it does |
|---|---|---|---|---|
| GET | `appointments` | closure | `appointments.index` | **302** → `appointments/list` |
| GET | `appointments/list` | `ShowroomController@appointments` | `appointments.list` | The book — table or `?view=calendar` |
| GET | `appointments/overview` | `ShowroomController@dashboard` | `appointments.overview` | Show-rate stats by agent / project / source |
| POST | `showroom/appointments/{id}` | `ShowroomController@record` | `showroom.appointments.record` | Record / clear an outcome, or cancel / un-cancel |

#### Upload & integrate

| Method | URI | Action | Name | What it does |
|---|---|---|---|---|
| GET | `ingest` | `IngestController@index` | `ingest.index` | Drop zone + the last 50 documents |
| POST | `ingest` | `@store` | `ingest.store` | Store 1–20 PDF/DOC/DOCX ≤ 100 MB each, dispatch `ClassifyDocument` |
| POST | `ingest/{id}/classify` | `@classify` | `ingest.classify` | Override the detected type, clear the confidence |
| POST | `ingest/{id}/approve` | `@approve` | `ingest.approve` | Create a **draft** `ContentItem` |
| DELETE | `ingest/{id}` | `@destroy` | `ingest.destroy` | Delete the row and its stored file |

#### Redirects to retired pages

Every one of these is a live route whose only job is to forward a URL that lives in browser histories or stale JS bundles.

| URI | Redirects to | Why the target is gone |
|---|---|---|
| `/` | `/manage/appointment-engine/dashboard` | The "Console" landing page only linked to other pages — that is the sidebar's job (`routes/web.php:1964-1967`) |
| `allocation` | `leads?team=incomplete` | Allocation removed 2026-09-07; staffing happens in the Leads Team cell's Assign modal, and `?team=incomplete` **is** the old queue (`:2082-2090`) |
| `automation` | `ai-agent` | The single-flow canvas died with the simple-editor redesign, 2026-09-06 (`:2112-2114`) |
| `content` | `ai-agent/knowledge` | Content moved under the AI Agent strip (`:2115`) |
| `showroom` | `appointments` | Renamed: a booking is Zoom **or** showroom, so the room is a badge, not the section (`:2062`) |
| `showroom/appointments` | `appointments/list` | same (`:2063`) |
| `appointments` | `appointments/list` | The list *is* the landing; Overview moved one tab in (owner, 2026-09-06) (`:2056`) |
| `calls/ai-profiles` | `ai-agent/profiles` | The profile pages moved beside the workflows that use them (`:2028`) |
| `calls/ai-profiles/{id}` | `ai-agent/profiles/{id}` | same (`:2033`) |
| `settings/{any?}` (`where any = .*`) | `/manage/messages/channels?suite=appointment` | The suite's Connections / Team / Billing pages were deleted 2026-09-07; Setting is seven host pages wearing `AeSettingTabs` (`:2125-2129`) |
| `ai-agent/workflows/{id}` | `…/projects/{uuid}?tab=workflow&wf={uuid}` (or `ai-agent` when the workflow has no project) | Controller-level redirect, not a closure — the canvas page is gone (`WorkflowsController.php:105-118`) |

There is **no `coverage` route**, although the Dashboard sidebar entry still lists `…/coverage` in its `prefixes`.

### 4. The screens

#### 4.1 Agency chooser — `GET agency` → `Pages/Manage/AppointmentEngine/Agency.vue`

The first thing platform staff see. `AgencyController::index()` redirects a user who already has a group (`$viewer?->groupId()`) straight to the dashboard — an agency member has nothing to choose.

Props: `agencies` (active `Group` rows with `teams`, `members` = `admins_count`, and `projects` = a `group_id`-grouped count of `ae_projects`), `platformProjects` (projects with a NULL group), `current` — `false` when never chosen this session, `null` for "all agencies", an int for a group id. The page POSTs `{ agency_id }` back to the same URI (`Agency.vue:23`); `select()` validates `nullable|integer|exists:groups,id`, calls `AeScope::choose()`, flashes "You are now working in {name}." and lands on the dashboard.

#### 4.2 Dashboard — `GET dashboard` → `Dashboard.vue`

The leader's home, redesigned 2026-09-07. Nothing on load spends AI money.

| Prop | Built by | Shows |
|---|---|---|
| `summary` | `FunnelSummary::build($viewer, null, $from, $to)` | The one band: leads by source (Google Sheet and Click-to-WhatsApp always rendered, zero included), calls `placed/spoke/refused`, WhatsApp `total/workflow/ai`, results `booked/attended/recorded` |
| `range` | `rangeWindow()` | `month` (default), `7d`, `3m`, `custom` (`?from`/`?to`, `Y-m-d`, falls back to month on a malformed pair) |
| `hero` | `hero()` | Greeting name, acting `agency`, `can_switch` (true only for a groupless viewer), and a **four**-step setup checklist: WhatsApp connected → First project → A workflow live → First AI booking. The old fifth step (commission value) left with the Billing page |
| `projectRows` | `ProjectFunnel::byProject()` | One row per active project: `leads / spoke / booked / attended`, `live`, `has_workflow` |
| `commission` | `commission()` | `rate` from `Setting::forGroup(...)->commission_per_appointment`; `basis` flips from `'booked'` to `'attended'` the moment any outcome is recorded; `value` is `null` (not 0) when the rate is untyped |
| `attention` | `attention()` | Up to four work queues, **rows with a zero count are dropped**: incomplete teams → `leads?team=incomplete`; unread threads and human-took-over threads → `/manage/messages?suite=appointment`; declined calls → `/manage/calls/ai-calls?suite=appointment&status[]=0` (`Call::STATUS_REFUSED = 0`) |
| `upcoming` | `upcoming()` | Next 7 days, `outcome IS NULL`, max 12, with `via` = `call`/`chat` and `meeting_type`/`meeting_link` |
| `trend` | `trends()['days']` | 14 daily `{day, leads, calls, appointments}`, oldest first |
| `questions` | literal | Four suggested analyst questions |

Mounts `Components/AiAgent/AgentChatPanel.vue` (posts to `dashboard/chat`) and `Partials/FunnelSummaryBand.vue`. `chat()` rebuilds a *fresh* snapshot per question — summary, stats, commission, week-on-week `compare`, `trends`, `pipeline`, project rows, attention, today, upcoming, chat stats and refusals — replays at most 12 history turns (`DashboardChatRequest`: `message` ≤ 2000 chars, `history` ≤ 12, each role `user|assistant`, content ≤ 4000), and returns `{reply, bars, table}` with `bars.items` capped at 8 and `table` at 5 columns × 10 rows. A provider failure returns **HTTP 503** with a plain-language message.

Scope: every dashboard query runs through `scope()` → `AeScope::apply()`, and `appointments()` additionally requires `appointments.ae_lead_id IS NOT NULL` — the suite reads only the rows the engine earned out of the merged appointment book.

#### 4.3 Projects hub — `GET projects` → `Projects/Index.vue`

`ProjectsController::index()` paginates `sharedProjects()` (platform rows + the acting agency's) with `knowledge_count`, `workflow_count`, `lead_count`, ordered live-first (`is_active DESC, name`) unless `?sort` is one of `SORTABLE = ['name', 'created_at']`. Each row carries the CRM bridge's money facts (`commission_rate`, `commission_basis_label`, `price_from/to`, `vp_at`, `is_catalogue_linked`) and its four funnel figures.

Mounts `DataTable`, `HighlightText`, `ConfirmModal` and — reused from the sales suite — `Pages/Manage/SalesProjects/Partials/ProjectFormModal.vue`, pointed at `store-url="/manage/appointment-engine/projects"` and `update-url` per row (`Projects/Index.vue:179-185`). Delete is refused server-side when the deal holds leads or workflows, with a message telling the user to archive instead.

#### 4.4 Project Show — `GET projects/{id}` → `Projects/Show.vue`

The hub page. Header actions: Edit (the same `ProjectFormModal`, `mode="edit"`), Archive/Unarchive (`PUT projects/{id}` with `is_active`), and a team switcher (`?team=`, honoured only when the team belongs to the acting agency — `teamFilter()`). Then `FunnelSummaryBand` over `summary`, then **three** `ShowTabs` tabs (`Projects/Show.vue:71-75`):

| Tab | Count | Body |
|---|---|---|
| `leads` | `funnel.leads` | `Partials/LeadsTable.vue` over a 15-per-page paginator (`projectLeads()`, searchable on name/phone/email, ordered `last_activity_at DESC`), plus the sheet strip (`sheet` prop) and the closer board (`closers`) |
| `appointments` | `funnel.booked` | `Partials/AppointmentsBook.vue` — table (paged by **`?apage`** so it never collides with the leads pager) or `?view=calendar` |
| `workflow` | `workflows.length` when > 1 | `Projects/Partials/WorkflowTab.vue` |

`planDetail` is served through `Inertia::optional()` — the Workflow tab fetches it by name on first open, `?wf=` picking which of the deal's workflows to edit; anything not belonging to this project silently resolves to the deal's own lead workflow instead of leaking another agency's (`ProjectsController.php:315-325`).

`WorkflowTab.vue` is a drag-and-drop canvas that **is** the editor: blocks are dragged for layout only, execution order is the step order, and edges are never editable. It writes through four endpoints — `POST ai-agent/workflows` (create), `PUT …/{id}/plan` (save, one document), `POST …/{id}/publish`, `DELETE …/{id}?stay=1` — plus `POST …/{id}/sheet-check` over axios for the sheet drawer's "Check now" (`WorkflowTab.vue:98, 141, 772, 775, 788`). It mounts `BookedChainCard.vue` and `TemplatePreview.vue`, and emits `link-sheet` up to the page's `SheetLinkModal`.

Two modals live on the page itself: `SheetLinkModal.vue` → `PUT projects/{id}/sheet`, and `ClosersModal.vue` → `PUT projects/{id}/closers`.

`updateSheet()` is worth reading before touching: it finds the workflow whose `plan.entry === 'sheet'` (**not** "the primary workflow" — the old code wired the sheet into the keyword flow, blocking publish with "more than one trigger" and letting the next save erase the URL), writes `entry_config.sheet_url`/`access` into the plan, recompiles, then runs the first sync in-request so nothing already in the sheet is ever called (`ProjectsController.php:643-682`).

The closer board (`closerBoard()`) shows one row per agency the viewer may see, plus a platform bucket for a groupless viewer. Eligible closers are active users holding `SALES_LEADER`, `SALES_AGENT` or `GROUP_SUPER_ADMIN` in that group — or, for the platform bucket, `Role::manageRoles()` with `admin.group_id IS NULL` (`ProjectsController.php:1037-1056`). `updateClosers()` intersects the submitted ids against that pool, then replaces that one group's list inside a transaction.

#### 4.5 Project → Sales — `GET projects/{id}/sales`

Not an AE screen: `ProjectsController::sales()` calls `app(SalesProjectsController::class)->show()` and adds `suite = 'appointment-engine'`, `selfUrl` (this URL) and `backUrl` (the project page), so `Pages/Manage/SalesProjects/Show.vue` renders inside the AE sidebar. Falls back with a flash when the deal has no CRM bridge, or when the viewer lacks `view-projects`.

#### 4.6 Leads index — `GET leads` → `Leads/Index.vue`

`LeadQueryRequest` (`extends ManageQueryRequest`) declares `$filterable = ['search', 'stage', 'agent', 'date_from', 'date_to', 'team']`:

- `search` — name `LIKE`, **or** the phone with `+`, `-` and spaces stripped, so `012-345` finds `+60123456789`.
- `stage` / `agent` — `applyIn()` multi-select.
- `date_from` / `date_to` — `applyDate()` on `ae_leads.created_at`.
- `team=incomplete` — `CrmPipeline::scopeIncompleteTeam()`; this is the retired Allocation queue.

Filter keys must stay digit-free: the resolver strips digits before studly-casing, so `stage_1` would look for `filterStage`.

Props: the paginator (rows through `LeadPresenter::row()`), `stages` = `Lead::STAGES`, `stageCounts` and `agentCounts` from grouped aggregates **over the same scope as the list** (so the chips cannot disclose the size of a book the viewer cannot open), `agents`, `total`, `sort`/`direction` (`SORTABLE = ['name','stage','created_at','last_activity_at']`), `workflows` (**live only** — offering a paused one would enrol a lead into something that will not run), `projects`, and the Assign-modal vocabulary `closingModes` / `pipelineRoles` / `assignableAdmins`.

Chips read in funnel order from `Lead::STAGES`, never by count: `1 New`, `2 AI called`, `3 Answered`, `4 Appointment`, `5 Showed up`, `6 Lost`. The drawer (`useResourceIndex`, `dateRange: true`) carries two dimensions only — Stage and Assigned agent; Intent and Buying journey left with their columns on 2026-09-07 though the AI still extracts them (`Leads/Index.vue:64-68`).

Mounts `Partials/LeadsTable.vue` (which itself mounts `LeadFormModal`, `ConfirmModal`, `TeamRolesCell` and the CRM's `AssignEngagementModal`), `FilterDrawer`, `ActiveFilterChips`, and a `Modal` for CSV import. Row actions: a WhatsApp button (`<a target="_blank">` to `leads/{id}/chat`), View, Edit and Delete (`LeadsTable.vue:146, 300, 303`).

#### 4.7 Lead Show — `GET leads/{id}`: two different pages

`LeadsController::show()` first re-attempts the CRM bridge when `lead_id` is null, then branches:

1. **Bridged + permitted** — `$lead->crmLead !== null` **and** `$viewer->canAny(explode('|', Permission::viewLeadsAny()))` **and** `LeadVisibility::allows($viewer, $lead->crmLead)` → it returns the CRM's own `Manage/Leads/Show` page rendered *at this URL*, decorated with `suite = 'appointment-engine'`, `backUrl = /manage/appointment-engine/leads` and `aeContext` (`project`, `project_uuid`, `stage_label`) which the CRM page renders as a violet chip linking back to the deal (`Pages/Manage/Leads/Show.vue:352-357`). The suite flag also hides the merge button and puts the Zoom tab in meetings-only mode.
2. **Otherwise** — the engine's native `Leads/Show.vue`, which adds what the AI heard: `intent`, `budget`, `timeline`, `journey_label`/`journey_color`/`journey_mismatch`, `source_campaign`, `self_reported_first_property`, `system_observed_viewings`, `first_contacted_at`. It embeds the CRM's own `IdentityTab` when `crm` is non-null, provides `leadReadonly` from `crm.can_manage` (the enrich endpoint demands `manage-leads`), fetches `/manage/leads/{uuid}/quick` on mount for the Channels card, and lists `runs` — each with `status_label`/`status_color` from `WorkflowRun::STATUSES`, `current_step`, `waiting_for`, `resume_at`, `last_error` and a 40-entry trail reversed to read oldest-first.

The bridge is one-directional on purpose: an unlinked lead is retried on every view, and a lead the CRM would refuse falls through to the native page rather than becoming a side door (`LeadsController.php:390-415`, `:434-446`).

#### 4.8 Lead endpoints the Messages inbox drives

`Pages/Manage/Messages/Inbox.vue` is the only caller of four AE endpoints, which is why they are exempt from the agency gate:

- `GET leads/{id}/flow` (axios, `?run=`) → `{ planDetail, progress, lead, project }`. `planDetail` is `WorkflowsController::planProps()`, the *same* payload the Workflow tab edits, rendered by `WorkflowTab.vue` with `readonly` + `progress` so each step wears the lead's state. Run selection order: `?run=` → the first open run → the latest; 404 if neither the run nor its workflow exists (`LeadsController.php:747-795`; `Inbox.vue:1249`).
- `POST leads/{id}/stop-sequence` · `stop-chat` · `stop-automation` (`Inbox.vue:1261`) → `ChatTakeover::stopSequence()` / `stopChat()` / `stopForLead()`, each flashing a sentence that names what was actually stopped and a reason line naming who pressed the button.

#### 4.9 Appointments list — `GET appointments/list` → `Showroom/Appointments.vue`

One filtered scope feeds **both** views and every counter (`ShowroomController.php:118`). `AppointmentQueryRequest` declares `$filterable = ['search','state','channel','booked_by','project','agent','date_from','date_to']`:

| Dimension | Tokens | Notes |
|---|---|---|
| `state` | `needs_marking`, `upcoming`, `attended`, `no_show`, `follow_up`, `closed`, `not_closed`, `cancelled` | The outcome tokens are **named, not the integers** (`OUTCOME_STATES` maps them to `Appointment::OUTCOME_ATTENDED=1`, `NO_SHOW=2`, `FOLLOW_UP_NEEDED=3`, `CLOSED=4`, `NOT_CLOSED=5`) — a JS object hoists integer-like keys, which would reorder the chips. `needs_marking`/`upcoming` are the same un-recorded outcome split by the clock; `cancelled` is `status = Appointment::STATUS_CANCELLED (7)` |
| `channel` | `showroom`, `zoom` (`Booking::CHANNELS`) | Ticking both is a no-op by design; `zoom` ⇒ `type = TYPE_VIDEO_CALL (2)`, `showroom` ⇒ anything else **or NULL** (pre-merge rows) |
| `booked_by` | `ai_call`, `ai_chat`, `human` | `human` includes `source IS NULL`, matching the row label "Booked by a person" |
| `project` | project **uuid** | via `whereHas('aeLead.aeProject')` |
| `agent`, `date_from`, `date_to` | | dates filter `appointments.scheduled_at`, not `created_at` |

Chip and drawer counts run **every filter except their own** (`counterQuery()` → `applyFiltersExcept()`), so the bar neither ignores the search nor zeroes its siblings. The project count query joins `ae_leads`/`ae_projects` and adds `whereNull('ae_leads.deleted_at')` by hand, because a join does not see the relation's soft-delete scope.

Table view: 30 rows, ordered **un-recorded past appointments first** (`CASE WHEN outcome IS NULL AND scheduled_at < NOW() THEN 0 ELSE 1 END`, then `scheduled_at DESC`), rows through `AppointmentRow::make()`. Calendar view (`?view=calendar&month=`): `AppointmentCalendar::props()` — the same builder the project tab and `/manage/calendar` use, so the two calendars cannot describe an appointment differently.

Mounts `SuiteTabs section="appointments"`, `AppointmentsBook.vue` (→ `AppointmentsTable`, `MonthGrid`, `DayDrawer`, `EventDetailModal`, and the shared `AppointmentFormModal`), `FilterDrawer`, `ActiveFilterChips`. Outcome recording posts to `POST showroom/appointments/{uuid}` from `AppointmentsTable.vue:60,66`.

`record()` accepts `outcome` (nullable, `Rule::in(array_keys(Appointment::OUTCOMES))`) or `cancelled` (boolean). Cancelling clears any outcome through `AppointmentRepository::recordOutcome($appointment, null)` first so the two facts cannot conflict, then writes `status`. Outcomes are written **only** through `AppointmentRepository::recordOutcome()` — the same function the CRM tab and the calendar modal call, which owns the lead-stage cascade and wakes parked runs; an `InvalidArgumentException` from it becomes a flash warning, not a 500.

#### 4.10 Appointments overview — `GET appointments/overview` → `Showroom/Dashboard.vue`

**Seven** counters (`total`, `recorded`, `attended`, `no_show`, `cancelled`, `awaiting`, `upcoming` — `ShowroomController.php:84-99`) plus `show_rate` and `coverage`, then `byAgent`, `byProject`, `bySource` tables. Two rules run through all of them: `recorded` counts `outcome IS NOT NULL` **or** `status = CANCELLED`; and every rate is `null` rather than `0` when nothing has been recorded, because a show rate over zero outcomes is unanswerable, not zero percent. `awaiting` (past its time, un-recorded, not cancelled) is the number that decides whether the rest of the page means anything. Mounts `SuiteTabs section="appointments"`.

#### 4.11 AI Agent → Workflows — `GET ai-agent` → `Workflows/Index.vue`

A flat list of every workflow the agency has (`uuid`, `name`, `description`, `project`, `is_active`, `steps` = `nodes_count`, `updated_at`), ordered live-first. Props also carry `projects` and `entries` — the three entry points a flow can be born from: `sheet` → "Google Sheet row", `ctwa` → "Click-to-WhatsApp ad", `keyword` → "WhatsApp keyword" (`Plan::ENTRIES = ['sheet','ctwa','keyword']`, mapped to node types in `Plan::ENTRY_TYPES`). Mounts `AiAgentTabs` and a create `Modal`; each row links to `ai-agent/workflows/{uuid}`, which immediately redirects into the owning project's Workflow tab.

#### 4.12 AI Agent → AI Profiles — `GET ai-agent/profiles` and `…/{id}`

`Manage\AppointmentEngine\AiProfilesController` **extends** the CRM's `Manage\Calls\AiProfilesController`, overriding exactly three seams (`AiProfilesController.php:65-80`):

```php
protected function profiles()            // AeScope::apply(AiCallProfile::query(), $viewer, 'ai_call_profiles.group_id')
protected function page(string $name)    // 'Manage/AppointmentEngine/Calls/AiProfiles/' . $name
protected function newProfileAttributes()// ['group_id' => AeScope::groupId(auth()->user())]
```

plus a constructor middleware calling `applyAgencyProviderConfig()`, which rewrites `services.retell.api_key`, `.agent_id` and `.from_number` for the rest of the request from the agency's **verified caller `Connection`** — so a sync provisions an agent on *their* Retell account and a test call is billed to *them*. With no connection the keys are set to `''`: the page still renders, and provider calls simply fail. This method is `public` but has **no external caller** — `grep -rn applyAgencyProviderConfig app/ src/ resources/` returns only its own file. It could be private; it builds the same profile detail outside this controller's middleware pipeline.

`Index.vue` shows a `DataTable` of Profile / Status / Knowledge / Calls / Retell sync with counts split so a profile's "12 calls" never silently includes the admin's own test runs (`calls_count` = `SOURCE_LEAD`, `test_runs_count` = `SOURCE_WEB_TEST`, `chat_tests_count`). It mounts `AiAgentTabs`, `PageHeader`, `ProfileFormModal` (create only) and an Import-from-Retell modal. `Show.vue` mounts `AiAgentTabs`, `PageHeader` and `Partials/ProfileDetail.vue` — the same component a host page embeds — which hosts the `SettingsTab`, `CopilotTab`, `TestLabTab` and `CallsTab`, plus `VoiceRecorder.vue`.

#### 4.13 AI Agent → Knowledge — `GET ai-agent/knowledge` → `Content/Index.vue`

`ContentController::library()` groups items **by project first, then category** — "Everything is not a shelf: an agent on an Armani Hallson call must not be reading another project's price list" (`ContentController.php:344-347`). Unfiled items sort last under "No project". Each row carries `kind_label`, `status_label`/`status_color`, `version`, `source_filename` and `entries` (how many records were parsed out of the file — the only honest signal that an upload became something usable). Props: `kinds`, `categories`, `statuses`, `projects`. Row actions: toggle live (`POST ai-agent/knowledge/{id}/live`) and delete. Mounts `AiAgentTabs` and `ConfirmModal`; the "Upload" button links to `/manage/appointment-engine/ingest`.

#### 4.14 Upload & integrate — `GET ingest` → `Ingest/Index.vue`

Three real steps: store → read (`ClassifyDocument` job) → **approve**. Upload validation: `documents` 1–20 files, each `mimes:pdf,doc,docx` and ≤ `102400` KB; and a project is mandatory — `new_project` is `required_without:project` ("Choose a project, or name a new one"), matched case-insensitively so "armani hallson" does not create a second "Armani Hallson". The upload loop runs **outside** any transaction; a file the disk refuses (`store()` returning `false` or `''`) is recorded as a `STATUS_FAILED` row rather than silently vanishing, and one bad file never loses the batch.

`classify()` sets `detected_type` and **nulls the confidence** — a number describing the model's certainty is a lie once a person overruled it. `approve()` aborts 422 when the type is missing or `TYPE_OTHER`, when nothing was extracted, or when the type has no content kind, then creates a `ContentItem` with `status = STATUS_DRAFT` via the fixed map `TYPE_FAQ → KIND_KNOWLEDGE`, `TYPE_CALLING_SCRIPT → KIND_SCRIPT`, `TYPE_WHATSAPP_SEQUENCE → KIND_SEQUENCE` — approving a *reading* is not approving the AI to *say* it. It redirects to `/manage/appointment-engine/content`, i.e. through the retired-URL redirect to the knowledge library.

The row payload shows the model's `reason` and up to 5 sample `entries` beside the guess, and `can_approve` gates the button. `pipelineReady` is a flag (currently `true`) rather than a hard-coded page claim, so the promise and the capability cannot drift.

#### 4.15 Hand-off accept / pass — `GET handoffs/{id}/accept|pass`

GET on purpose: they are tapped from a Telegram notification, where only a link can be followed. Both are idempotent — a second tap on a settled offer flashes one of three sentences from `settledSentence()` (already accepted / already passed / expired) instead of changing anything — and both `redirectWithSuite()` to the lead so the closer sees what they took or gave up. Accept force-fills `status = STATUS_ACCEPTED`, `accepted_at`, `note = 'Accepted'` and logs to the run; Pass calls `CloserRotation::advance()` and reports either who it went to or that the team was alerted because the pool was exhausted.

### 5. Shared building blocks the screens depend on

| Component | Used by | Purpose |
|---|---|---|
| `Partials/LeadsTable.vue` | Leads index, Project Show → Leads | One table shape for both, `showProject` toggling the deal column |
| `Partials/AppointmentsBook.vue` + `AppointmentsTable.vue` | Appointments list, Project Show → Appointments | Table ⇄ calendar toggle, outcome recorder, day drawer |
| `Partials/FunnelSummaryBand.vue` | Dashboard, Project Show | The single band, fed by `FunnelSummary::build()` |
| `Partials/LeadFormModal.vue` | LeadsTable | `POST leads` / `PUT leads/{uuid}` |
| `Partials/SuiteTabs.vue`, `Partials/AiAgentTabs.vue` | Appointments pair; AI Agent trio + Ingest | The two in-suite strips |
| `Components/AppointmentEngine/AeSettingTabs.vue`, `AeTeamTabs.vue` | Host pages under `?suite=appointment` | Setting (7 tabs) and Team (3 tabs) — no AE controller behind either |
| `Pages/Manage/SalesProjects/Partials/ProjectFormModal.vue` | Projects index + Show | One project, one form, across both suites |
| `Components/Sales/TeamRolesCell.vue` + `Pages/Manage/Leads/Partials/Pipeline/AssignEngagementModal.vue` | Leads and Appointments tables | The Team cell and its Assign modal, fed by `SharesClosingConfig` props |

## Dead code found while writing this doc (2026-09-09)

Two files in the suite are **no longer reachable** and were missed by every screen inventory because nothing routes to them:

| File | Why it is dead |
|---|---|
| `app/Http/Controllers/Manage/AppointmentEngine/ConsoleController.php` | The spec-era "console shell" that rendered the build plan while the real screens were being written. `grep ConsoleController routes/*.php` returns nothing — the suite root now redirects straight to the Dashboard (`Route::get('/', fn () => redirect('/manage/appointment-engine/dashboard'))`). |
| `resources/js/Pages/Manage/AppointmentEngine/Console.vue` | The page that controller rendered. |

They are harmless but misleading: a reader grepping for "console" finds a controller that looks like a live surface. Delete both when someone is next in the area — nothing imports them.

## Three structural facts a reader will otherwise get wrong

### 1. Two AE URLs render ANOTHER module's controller

| URL | What actually renders |
|---|---|
| `GET leads/{id}` | `app(CrmLeadsController::class)->show(...)` — **the CRM Lead page at the AE URL** (`LeadsController.php:404`), with `suite`, `backUrl` and an `aeContext` chip. `Pages/Manage/AppointmentEngine/Leads/Show.vue` is only the **fallback**, taken when the lead is unbridged, or the viewer fails `canAny(Permission::viewLeadsAny())`, or `LeadVisibility::allows()` (`:397-401`) |
| `GET projects/{id}/sales` | `app(SalesProjectsController::class)->show(...)` (`ProjectsController.php:586`) |

⚠️ Edit `AppointmentEngine/Leads/Show.vue` expecting to change what a normal bridged lead shows and **nothing will happen** — you are looking at the fallback.

Related: `planProps()` on `WorkflowsController` is deliberately `public` — one plan read-model shared by three surfaces (`LeadsController.php:768`, `ProjectsController.php:323`).

### 2. Two GET routes WRITE

`GET leads/{id}` calls `CrmIdentity::ensureLinked($lead)` (`LeadsController.php:386-388`), which can mint a CRM lead; `GET projects/{id}/sales` calls `CrmProject::ensureLinked($project)`, which can create/link a sales project. Both are the opposite of GUIDELINES §3 — deliberate (the bridge gets one more chance on every view) but worth knowing before you cache or prefetch either URL.

### 3. The module enforces SIX permissions, not one

`view-appointment-engine` gates the route group; it is not the whole story.

| Where | Gate |
|---|---|
| route group (`routes/web.php:1958`) | `view-appointment-engine` + `auth`, `admin`, `ae.agency` |
| `ProjectsController::updateClosers` | `manage-appointments` (403) **and** `AeScope::allows()` (403) |
| `ProjectsController::update` (price/commission branch) | `manage-projects` — else the name is saved alone, with a flash |
| `ProjectsController::sales` | `view-projects` — else redirect + flash |
| `HandoffsController` | the offered closer **or** `manage-appointments` (404 otherwise) |
| `LeadsController::show` | `canAny(Permission::viewLeadsAny())` + `LeadVisibility` |
| `LeadsController` (agent narrowing) | literal `'manage-appointment-engine'` + `hasRole('sales-agent')` |
| `CloserRotation` default pool | **`sales-execution`** (`:352`) over `Role::manageRoles()` + `STATUS_ACTIVE` (`:338`) |

`sales-execution` deserves attention: a closer without it is invisible to the default `POOL_GROUP` arm.
