# Portal Engagement (Manage)

**Portal:** Manage · **Routes:** `manage.portal.*` — the whole prefix (and the sidebar entry) gated on **any leads view level** (`Permission::viewLeadsAny()`, 2026-08-12): the pages are member names + behaviour, so they follow lead visibility — every sales role has a level and keeps them; a role with none (the **Marketing** role) has no member world to read. · **Nav:** Channel → **Portal** — ONE sidebar entry landing on the Dashboard; the tabs (**Dashboard** first, then the pivots Wealth Planning / **Financial Report** / Analyze Property / Property Concierge / AI Conversations / **Courses** / **Area Tutorials** / **AI E-Learning**) are in-page hub tabs ([Components/Portal/PortalEngagementTabs.vue](/resources/js/Components/Portal/PortalEngagementTabs.vue) over the shared [HubTabs](/resources/js/Components/HubTabs.vue), underline style). **Three main tabs carry a second level** (pills), each grouping views of ONE subject rather than letting them compete at the top level (2026-09-02):

- **Dashboard** → **Dashboard** / **AI Agent** / **Activity** — the read-across, the member-facing AI's own page, and the raw cross-module timeline. Three reads of one subject, so one tab.
- **Wealth Planning** → **Wealth Plans** / **FPA** (`/manage/fpa`) / **Consent** (`/manage/fpa/consents`) — everything we know about one customer's MONEY: the plan they built, the analysis we ran on it, and the consent that let us run it. How a lead's FPA is built (one consent → WhoPay report → analysis) is in the [FPA handbook](/docs/modules_handbook/manage/fpa/readMe.md). **Deal Presets** stay a button on the plans page (they configure the plans rather than being another view of them) and light the Wealth Plans pill through the `/manage/wealth` prefix.
- **Analyze Property** → **Activity** — the members' saved-analyses pivot itself — then the **Subsale Database** (the canonical project catalogue at `/manage/property/catalog`), the **New Project Database** (the scraped new-launch review at `/manage/property/catalog/new-projects`) and **Developers** (`/manage/property/developers`), the canonical company and relationship setup used by both databases' project editor. All three data-management pills are gated like their routes (`view-projects` OR `view-subsale`).

Active main tabs and pills both resolve by longest MATCHING prefix — `/manage/fpa` is a prefix of `/manage/fpa/consents`, so a first-match would light FPA while the reader stands in Consent. See the [Project Catalogue handbook](/docs/modules_handbook/shared/project-catalogue/readMe.md). The former **Apps** placeholder tab (`/manage/apps`, native-app behavior tracking) was **removed 2026-08-04** — tab, route, controller and page; nothing was ever built behind it.

**AI Agent (second tab).** `/manage/portal-engagement/ai-agent` — the MEMBER-facing AI's own
page (the fifth agent, same shared `Components/AiAgent/` robot + chat, prompt
**`portal_agent_chat`**): what PropertyLab AI did for members (answers given / members served /
analyses / scout sessions / debates / context-aware chats), its cost, a work diary from
`ai_requests` (portal prompt keys), and next-steps (quiet member chats worth a human touch).
`Manage\Portal\PortalAiAgentController`; ungated like the group.

**Dashboard (first tab).** `/manage/portal-engagement/dashboard` — the engagement read-across:
reach tiles (active members / touches / analyses / AI chats / lessons done / video minutes), the
activity rhythm stacked by feature (shared `AgentTrendChart`), **feature adoption by DISTINCT
members** (not just touches), and the most-engaged members table (linked to Leads).
`Manage\Portal\PortalDashboardController` — one `UNION ALL` over the same five sources the
Activity feed uses, with the SAME happened-at expressions, so the dashboard and the feed can
never disagree about what counts as a touch. Ungated like the rest of the group.

## What it does
A read-only discovery layer answering *"what's happening in the customer portal?"* — an Activity timeline plus four [§14 DataTable](/GUIDELINES.md) record-pivot index pages under one sidebar group:

- **Activity** — the five behaviors merged into ONE timeline, newest first, with a summary tile per behavior (total / distinct leads / last 30 days). The pivots each answer "what exists in module X"; Activity answers "what happened", which none of them can alone. It sits FIRST in the strip for that reason. A `UNION ALL` over the five tables aligned to `(type, id, lead_id, happened_at)`, paginated on the merged timeline, then hydrated per type for just the page's rows (one query per type present + one for the leads — never per row). `happened_at` is each record's **latest portal touch**, not always `created_at` (wealth `updated_at`, concierge `last_active_at`, conversation `last_activity_at`, lesson `completed_at ?? last_viewed_at`; an analysis is an immutable run so its `created_at` IS its activity). The **tiles double as the type filter** (click to focus, again to clear) — so the type filter deliberately narrows the feed only, never the tiles, which must keep showing all five to stay comparable; search (by lead, via [`ActivityQueryRequest`](/app/Http/Requests/Manage/Portal/ActivityQueryRequest.php) — the feed is a union no single-model QueryRequest can express, so §9's filter logic lives there as a *lead-id resolver* the controller constrains every branch with) narrows both. `?type=` is whitelisted against `ActivityController::TYPES`.
- **Financial Report** (`/manage/portal-engagement/financial-reports`, 2026-09-18) — the FPA WORKLIST: one row per consent with how far that customer got (consent sent / signed · questions answered · WhoPay credit report · the shareable report link), four pipeline tiles, and a row action into `/manage/leads/{uuid}?tab=portal&ptab=fpa`. Its own MAIN tab rather than a fourth Wealth Planning pill: the pills there are records of one module each, this is the list an agent opens to see what to chase. Details in the [FPA handbook](/docs/modules_handbook/manage/fpa/readMe.md).
- **Four record pivots** — **Wealth Planning**, **Analyze Property**, **Property Concierge**, **AI Conversations**: every member's records of that type, with the owning **lead as a column**. Pure lists; row clicks open the shared **read-only Lead detail modal** (or land on the existing analysis surface) — this module adds **no new detail pages**.

> The former **Users** page was removed 2026-07-21: the [Leads index](/docs/modules_handbook/manage/leads/readMe.md) now carries the same per-lead portal-engagement aggregates (analyses / plans / conversations / webinars + activity), so a separate portal-users list was redundant.

### Activity → 训练 (2026-09-03)
The Activity page has ONE pivot, `?view=training` (a segmented control under the title — a pivot is not a
tab, GUIDELINES §15): the five-day training's trainees, i.e. the name list for the Day-3 「which unit to
buy」 session. `ActivityController@training` (reached through `index()` on `?view=training`,
`TrainingQueryRequest` for search / cohort / status) loads the enrolments and their leads' live road
cycles in two queries and derives every row with `Src\Journey\Support\TrainingProgress::of()` — the
day they are on (the first program day whose cards are not all complete, `TrainingAccess::day()`'s
rule), the seven cards done/not with dates, where they are waiting and for how many days, the last
card touched. The **day filter and the sort are applied to the computed rows** and the page is cut
after (a cohort is tens of people). The funnel strip (`TrainingProgress::funnel()`) counts who reached
each day's end and the **median hours between consecutive days' last cards** — labelled as pace, never
as "minutes on a screen", because the road records when a card completed, not how long it was read.
Page: [Pages/Manage/Portal/Activity/Training.vue](/resources/js/Pages/Manage/Portal/Activity/Training.vue)
(+ `Partials/ActivityPivot.vue`, mounted on both views). Test: `tests/Feature/Manage/Portal/TrainingPivotTest.php`.

## How it works
- **No tables of its own.** Everything reads existing tables (`leads` + `users`, `wealth_plans`, `property_analyses`, `concierge_requests`, `ai_conversations`); the only migrations it owns are read-path indexes (see the sort-buffer note below).
- **Pivot lists must never `select *`.** These tables carry wide JSON snapshot columns (`property_analyses.result`, and friends) that no list column reads. MySQL pulls *every selected column* through its sort buffer when ordering, so `select *` + `order by created_at desc` on an owner-unfiltered pivot overflows `sort_buffer_size` and throws `SQLSTATE[HY001] 1038 Out of sort memory`. Analyses therefore pins an explicit `AnalysesController::$listColumns` projection, and `property_analyses` carries a `(deleted_at, created_at)` index so the default page needs no filesort at all. **Adding a sortable column to a pivot means adding it to that projection** — and any new pivot over a JSON-carrying table should start from the same two habits.
- **Pivot lists** each follow one thin pattern: `Model::with('lead.user.profile')` → `QueryRequest` filters → `applySort` → paginate → `transform`. The **Lead column sorts via a correlated subquery** on `user_profiles.full_name`; the Conversations message count is a `withCount('messages')` subquery.
- **String-column filters use `applyInString`** (`goal_type`, `verdict`, `property_purpose`) — `applyIn` is intval-only and would match nothing. Concierge `status` is an integer → `applyIn` is correct.
- All lists use the shared kit: `ResolvesListQuery::listControls` (whitelisted sort / direction / per_page), `ManageQueryRequest` filter helpers, and the Vue `DataTable` / `FilterDrawer` / `ActiveFilterChips` / `ExportMenu` components driven by `useResourceIndex`.

## Related files

**Backend — Controllers** ([app/Http/Controllers/Manage/Portal/](/app/Http/Controllers/Manage/Portal/))
- [WealthPlansController.php](/app/Http/Controllers/Manage/Portal/WealthPlansController.php) · [AnalysesController.php](/app/Http/Controllers/Manage/Portal/AnalysesController.php) · [ConciergeController.php](/app/Http/Controllers/Manage/Portal/ConciergeController.php) · [ConversationsController.php](/app/Http/Controllers/Manage/Portal/ConversationsController.php) — the four pivots (index only).
- [ActivityController.php](/app/Http/Controllers/Manage/Portal/ActivityController.php) — the merged Activity timeline (`TYPES` whitelist, union feed, per-behavior summary) + [ActivityQueryRequest.php](/app/Http/Requests/Manage/Portal/ActivityQueryRequest.php) and [Pages/Manage/Portal/Activity/Index.vue](/resources/js/Pages/Manage/Portal/Activity/Index.vue).

**Backend — Query Requests** ([app/Http/Requests/Manage/Portal/](/app/Http/Requests/Manage/Portal/))
- [WealthPlanQueryRequest.php](/app/Http/Requests/Manage/Portal/WealthPlanQueryRequest.php) · [AnalysisQueryRequest.php](/app/Http/Requests/Manage/Portal/AnalysisQueryRequest.php) · [ConciergeQueryRequest.php](/app/Http/Requests/Manage/Portal/ConciergeQueryRequest.php) · [ConversationQueryRequest.php](/app/Http/Requests/Manage/Portal/ConversationQueryRequest.php) — per-pivot search + `applyInString` / `applyIn` filters + date ranges.

**Backend — Consumed (not modified)**
- Models: [Lead](/src/Lead/Lead.php), [User](/src/People/User.php) (`STATUSES`), [WealthPlan](/src/Wealth/WealthPlan.php) (`GOAL_TYPES`), [PropertyAnalysis](/src/Analysis/PropertyAnalysis.php) (`VERDICTS`, `PROPERTY_PURPOSES`), [ConciergeRequest](/src/Concierge/ConciergeRequest.php) (`STATUSES`, `SERVICES`), [AiConversation](/src/Ai/AiConversation.php).
- Traits: [ResolvesListQuery](/app/Http/Controllers/Concerns/ResolvesListQuery.php); base [ManageQueryRequest](/app/Http/Requests/Manage/ManageQueryRequest.php).

**Frontend (Vue)** ([resources/js/Pages/Manage/Portal/](/resources/js/Pages/Manage/Portal/))
- [WealthPlans/Index.vue](/resources/js/Pages/Manage/Portal/WealthPlans/Index.vue) · [Analyses/Index.vue](/resources/js/Pages/Manage/Portal/Analyses/Index.vue) · [Concierge/Index.vue](/resources/js/Pages/Manage/Portal/Concierge/Index.vue) · [Conversations/Index.vue](/resources/js/Pages/Manage/Portal/Conversations/Index.vue) — the four pivots, one shared visual language.
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — the single "Portal" sidebar entry; its `prefixes` also cover `/manage/wealth`, `/manage/courses`, `/manage/property/catalog`, `/manage/property/developers` (Analyze Property's data-management pills) and `/manage/ai-elearning`, which sit outside the `/manage/portal-engagement` prefix.
- [resources/js/Components/Portal/PortalEngagementTabs.vue](/resources/js/Components/Portal/PortalEngagementTabs.vue) — the hub strip mounted by every page in the hub (incl. the Project Catalogue index and the deal-presets page, which resolve to their owning main tabs via prefixes).
- **Wealth Planning → Deal Preset Setting** opens the [deal presets](/manage/wealth/deal-presets) (`Pages/Manage/Wealth/DealPresets/Index.vue`, routes `manage.wealth.deal-presets.*`). The presets shape the plans this page lists, so they are reached from a button here instead of a nav entry of their own; that page carries a "Back to Wealth Planning" link, and the group's `prefixes` keep it lit while you are on it.
- **Courses** ([LMS](/docs/modules_handbook/manage/lms/readMe.md)) moved into this group — a course is something the portal engages a member with, not a back-office setting.

**Routes**
- [routes/web.php](/routes/web.php) — the `manage.portal.*` group (`portal-engagement` prefix).

**Tests** ([tests/Feature/Manage/Portal/](/tests/Feature/Manage/Portal/))
- [PortalPivotPagesTest.php](/tests/Feature/Manage/Portal/PortalPivotPagesTest.php) — the four pivots: row shape, lead column, filters (incl. the `applyInString` string-column regression).
