# Employee Performance (CEO suite)

**Portal:** Manage · **Suite:** `ceo` · **Routes:** `manage.ceo.employee-performance.*` · **Nav:** Employee Performance (tabs: Property Closing · Sales Engagement · Video Editor); an editor who holds only `log-video-productions` gets a one-line sidebar, **My Videos** · **Permission:** `view-ceo-dashboard` + `view-projects` (Property Closing, Sales Engagement) · `view-ceo-dashboard` **or** `log-video-productions` (Video Editor)

Part of the [CEO Dashboard](/docs/modules_handbook/manage/ceo-dashboard/readMe.md) suite. Built 2026-09-28 at the founder's request: *"add a new section called Employee Performance … the first tab I wanna track is Property Closing"*, then the same day *"a new tab called Sales Engagement Performance … then third tab is video editor performance"*.

## What it does

How the team is doing, one tab per thing they are measured on.

- **Property Closing** — every lead who **paid a project fee** and so went into that project's sales pipeline, **across every project**, in the format of one project's Leads tab (`/manage/sales-projects/{uuid}`), led by two columns of its own: **Payment** (amount + date — the list is *always* sorted by it) and **Zoom Closer** (who runs the 1-1 sales Zoom, picked inline, filterable).
- **Sales Engagement** — two tables over a period (Today … Last 30 days, custom):
  - **Sales engagement team** (Ke Xin, Shawn, Zen, Boon by default): **leads talked to** → 1-1 Zooms (count · total · average length) and phone calls (count · total · average) → total talk time → how many leads had a **meaningful** conversation (AI) → how many of those then **came to a property sales webinar** → how many then **paid into a pipeline**.
  - **Sales closer team** (Wai Kit, Ke Xin, Shawn, Zen, Boon): **sales Zooms** held after payment → **deals handled** (paid leads in the closer's hands) → **closed** (booked a unit) → **converted**.
  Each board opens with the TEAM's funnel (distinct leads/deals — a lead two people talked to is one lead), then one row per person. Each rate sits **directly under** its number with a thin meter (meaningful % of leads, webinar % of meaningful, paid rate = paid ÷ meaningful; close rate = closed ÷ deals, conversion = converted ÷ closed). **Every heading explains itself on hover** (`Partials/HeaderHint.vue`), and **every number lists its leads on hover** (`Partials/MetricCell.vue` over the shared [`Components/HoverCard.vue`](/resources/js/Components/HoverCard.vue)) — *which webinar* they came to, *which project* they paid for and *who is closing it*, a deal's status — and opens the full list on click.
- **Video Editor** — the video log: per editor, videos finished in the period, total minutes, how many were posted anywhere / not yet, what is in progress, and days since she last logged anything; below, every video with its Drive link and the platforms it went up on. **The editor logs each video herself** — nothing recorded this before.

Example: Ke Xin phones 陈先生 on 1 Sep and the AI calls it a follow-up where he is interested → one **meaningful** lead for Ke Xin. He attends the Grade A webinar (ticked as a property sales webinar) on 5 Sep → **came to webinar**. He pays the RM100 Binastra fee on 6 Sep → **paid**, and he appears on Property Closing. Zen is named his Zoom closer and holds the Zoom on 8 Sep → one **sales Zoom** and one **deal** for Zen; when the unit is booked it counts as **closed**.

## How it works

### Nav
- One sidebar entry, **Employee Performance**, landing on Property Closing; the strip is `SectionTabs` section `employee-performance` (`prefixes: ['/manage/ceo/employee-performance']`). Tab gates mirror their routes: `permissionAll: ['view-ceo-dashboard','view-projects']` for the two sales tabs — `SectionTabs` learned `permissionAll` for this — and `permissionAny: ['view-ceo-dashboard','log-video-productions']` for Video Editor. An editor-only account therefore sees one tab (no strip) and a sidebar entry **My Videos** (`unless: 'view-ceo-dashboard'`).
- Everything lives under `/manage/ceo`, which `useSuite.js` `OWNED_PATHS` gives to the `ceo` suite.

### Who counts as "paid" — `Src\Engagement\Support\PaidPipelines` (one rule, three readers)
A pipeline is paid when its lead has a project-fee payment that moved money (**Active**, or **Refunded** since) and was **not deleted**, which is either **linked** to it (`engagements.purchase_history_id` — `PurchaseFulfiller::fulfillProject()` stamps it) or made by **this lead for this project** without the link (11 pipelines from July–August 2026 carry none). `paidAtSql()` gives *when*: the linked receipt's date, else the lead's latest receipt for that project. Read by Property Closing (rows + sort), and by Sales Engagement's *paid* and closer counts.

### Property Closing
- **Payload built by `SalesProjectsController`** (`paidPipelinesPayload()` / `paidPipelinesExport()`), not copied — same row serializer (`engagementCard`), status strip, rail and card maths (`summariseLeads`, which a project's `leadSummary` also calls). [`PropertyClosingController`](/app/Http/Controllers/Manage/Ceo/PropertyClosingController.php) only renders the page and names the file. Same delegation as the Appointment Engine's sales tab.
- **Payment column** — each row carries `paid` (display: amount, currency, date, `linked`) from `paidPipelineCard()`: the linked receipt, else this lead's own receipt for the project (`unlinkedProjectFeeReceipts()`, one query per page) marked *not linked*. ⚠️ `payment` (the status cell's chip, hidden here via `EngagementTable`'s `payment-column`) stays **the link alone**, because the Assign / Booking modals preselect `payment.id` and a save writes it back — a guessed receipt there could flip the deal's closing mode. An amber note counts the unlinked rows (`unlinkedPaid`) and points to Payment History → 🔗.
- **Always by payment date** — `PAID_SORTS = ['paid']`; the payload echoes `sort: 'paid'` even on a bare visit, the header flips newest/oldest, nothing else sorts. The id is the tiebreaker.
- **Zoom Closer** — `engagements.zoom_closer_id` (users.id). **Its own column, not a pipeline role**: the payment-default closing mode ("Webinar Closing") pools only Lead Gen / Analyst / Webinar Closer / Follow Up, so as a role it would earn nothing and `rollTeamForPayment()` would move its holder onto Follow Up. It moves **no commission**. Set inline (a select of the Assign modal's staff pool) → `PUT /manage/engagements/{id}/zoom-closer` → `EngagementRepository::setZoomCloser()`; a non-manage uuid is refused (422), blank clears. Filter `zoom_closer[]` (a teammate's uuid, or `none` = not assigned) in `PropertyClosingQueryRequest`, which extends the booking list's `BookingQueryRequest`.
- **Pinned columns** — with `payment-column` on, `EngagementTable` pins Payment (104px) + Zoom Closer (128px) + Lead (168px) (`stickyColumns`, counted off what the column chooser actually shows) and uses the compact `#` column — 456px pinned, down from 564px (2026-09-29, founder: columns too wide). The shared status pill went from `w-40` to `w-32` on every engagement table (it still holds "Appointment Set").
- Cards follow every filter but status; the rail every filter but status and teammate; chips are status-blind. Filters: search, status, project, bank, booked dates, teammate, Zoom closer. Export (`…/property-closing/export`) leads with *Paid On · Paid Amount · Currency · Payment · Payment Linked · Zoom Closer* (`ProjectLeadsExport` `withPayment`).
- The paid set is resolved once per request into ids (`$paidEngagementIds`) — the correlated `EXISTS` cost 150–500 ms per run on the remote DB. ~2 s for 66 rows.

### Sales Engagement — [`SalesEngagementReport`](/src/Ceo/Services/SalesEngagementReport.php)
- **Sessions** are `ChannelLeaderboard::countableRows()` — the Leads dashboard's own definition: a lead is linked, it is not a colleague, a Zoom actually happened (no webinars). Phone = `call_recordings`, Zoom = `zoom_meetings`; both carry `admins.id`, teams carry `users.id` (mapped through `admins.user_id`).
- **Meaningful** — [`MeaningfulConversation`](/src/Ceo/Support/MeaningfulConversation.php): the stored `ai_analysis` has `conversation_type` in *sales_pitch, follow_up, cold_call, negotiation, closing* **and** `customer.interest_level` in *high, medium, low*. There is no "meaningful" field anywhere; this is the rule. Unanalysed calls never count and are shown as "N not analysed".
- **Talk time** — Zoom `duration` is MINUTES (booked or recorded length), a call's `duration_seconds` is seconds; both are summed as seconds (`talkStats()`: count · total · average).
- **Came to webinar** — attended (≥ `WebinarHistory::ATTENDED_SECONDS`, summed per webinar) a webinar with **`zoom_webinars.is_property_sales`**, on or after the day of that salesperson's first meaningful talk with the lead; each lead carries the webinar names it came to. **How a webinar becomes a "property sales webinar":** the strip at the top of the page → **Choose / Tick your webinars**. The picker groups sessions by **series** — the name with its trailing session date removed (`SalesEngagementController::seriesName()`: "Cochrane 新盘线上讲座 - 5 September" and "- 1 September" are one series) — so ticking a name ticks every session; expand a series to tick sessions one by one. `PUT …/webinars` takes a LIST (`webinars[]` uuids + `is_property_sales`) → `ZoomWebinarRepository::update` per session; local-only, nothing is sent to Zoom (171 webinars here mix sales sessions with tests and rehearsals — nothing in Zoom's data says which is which). A NEW session of an already-ticked series is not ticked automatically. Until one is ticked the column reads 0 and the page says so.
- **Paid** — a `PaidPipelines` pipeline whose payment is on or after that same day; each carries its project and its closer (`closerNames()`: the named Zoom Closer, else whoever held a 1-1 Zoom with the lead on or after the payment day, else "No closer yet").
- **Credit** — two salespeople who both talked meaningfully to one lead are **both** credited: the question is what each person's calls led to.
- **Closer** — a *sales Zoom* is a happened 1-1 Zoom the closer hosted with a lead who had **already paid** (payment day ≤ the Zoom), or one booked as *Zoom Meeting post Booking Payment*; if the Zoom is paired to an opportunity (`zoom_meetings.engagement_id`) only that deal counts. *Deals* = those Zooms' paid pipelines ∪ pipelines naming them **Zoom closer** whose payment falls in the period. *Closed* = status Booked / Following Up / Converted; *converted* = Converted. Close rate = closed ÷ deals, conversion = converted ÷ closed (a project Leads tab's two rates).
- **Teams** — `performance_team_members` (`team` = `engagement` | `closer` | `video_editor`, `position`); there is no sales-team structure elsewhere (`teams` holds one test row). Seeded by email with the founder's lists; edited on the page (`PUT …/teams/{team}` → `PerformanceTeamRepository::sync`, the whole list every time). The two sales teams pick from the Assign modal's pool (`sales-execution`), video editors from the whole staff pool.
- ~0.5 s on this install.

### Video Editor — the video log
- `video_productions` (title, `editor_id`, `requested_by` "made for", status In progress / Done, `duration_seconds`, started / finished dates, Drive link, notes; soft-deleted with blame) + `video_production_posts` (one per platform: YouTube, Facebook, Instagram, TikTok, Xiaohongshu, Douyin, Other; optional URL + date). Length is entered as minutes + seconds. A *Done* video needs its finished date — that is the day it counts on.
- [`VideoProductionsController`](/app/Http/Controllers/Manage/Ceo/VideoProductionsController.php): `view-ceo-dashboard` sees and edits everyone's; `log-video-productions` alone sees **only their own** and is always the editor of what they log (an `editor` in the payload is ignored); another person's video 404s. `VideoProductionRepository` treats `video_production_posts` as the complete list — unticking a platform un-posts it.
- The list shows the period's finished videos **plus everything still in progress** whatever its start. "Last logged Nd ago" turns amber after 3 days — the quiet-editor signal.
- ⚠️ **Mei Xin (the editor, user 15643) holds the legacy `admin` role**, which carries `view-ceo-dashboard` and `view-projects` — so today she sees the owner's view of all three tabs, not "My Videos". To limit her to her own log, move her to a role with `log-video-productions` only (Manage → People → Roles). The permission row is created by migration `2026_09_28_190400` and granted to nobody.

## Related files

**Backend**
- [PropertyClosingController](/app/Http/Controllers/Manage/Ceo/PropertyClosingController.php) · [SalesEngagementController](/app/Http/Controllers/Manage/Ceo/SalesEngagementController.php) · [VideoProductionsController](/app/Http/Controllers/Manage/Ceo/VideoProductionsController.php) · [PerformanceTeamsController](/app/Http/Controllers/Manage/Ceo/PerformanceTeamsController.php) · [PropertySalesWebinarsController](/app/Http/Controllers/Manage/Ceo/PropertySalesWebinarsController.php)
- [SalesProjectsController](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php) — `paidPipelinesPayload` · `paidPipelinesExport` · `paidEngagements` · `paidPipelinesQuery` · `paidPipelineSet` · `paidPipelineCard` · `unlinkedProjectFeeReceipts` · `summariseLeads` · `floorPlanCardsByProject` / `officialFloorPlans` / `floorPlanCard`
- [EngagementsController](/app/Http/Controllers/Manage/Engagements/EngagementsController.php) `updateZoomCloser` · [ZoomCloserRequest](/app/Http/Requests/Manage/Engagements/ZoomCloserRequest.php) · `EngagementRepository::setZoomCloser` · `Engagement::zoomCloser()`
- [PaidPipelines](/src/Engagement/Support/PaidPipelines.php) · [SalesEngagementReport](/src/Ceo/Services/SalesEngagementReport.php) · [MeaningfulConversation](/src/Ceo/Support/MeaningfulConversation.php)
- [PerformanceTeamMember](/src/Ceo/PerformanceTeamMember.php) · [PerformanceTeamRepository](/src/Ceo/Repositories/PerformanceTeamRepository.php)
- [VideoProduction](/src/Video/VideoProduction.php) · [VideoProductionPost](/src/Video/VideoProductionPost.php) · [VideoProductionRepository](/src/Video/Repositories/VideoProductionRepository.php) (+ facade)
- Requests: [PropertyClosingQueryRequest](/app/Http/Requests/Manage/Ceo/PropertyClosingQueryRequest.php) · `Manage/Ceo/EmployeePerformance/{UpdateTeamRequest,UpdatePropertySalesWebinarRequest}` · `Manage/Ceo/Videos/{StoreRequest,UpdateRequest}`
- `ZoomWebinar::is_property_sales` + `ZoomWebinarRepository::update` · `Permission::LOG_VIDEO_PRODUCTIONS` · [ProjectLeadsExport](/app/Exports/ProjectLeadsExport.php) (`withProject`, `withPayment`)

**Frontend**
- `resources/js/Pages/Manage/Ceo/EmployeePerformance/{PropertyClosing,SalesEngagement,VideoEditor}.vue` + `Partials/{TeamPickerModal,WebinarPickerModal,LeadListModal,VideoFormModal,HeaderHint,MetricCell}.vue`
- [HoverCard.vue](/resources/js/Components/HoverCard.vue) — shared hover/focus card, teleported to `<body>` (AppShell's `@container` breaks in-place fixed panels), stays open while the pointer moves into it
- [EngagementTable.vue](/resources/js/Components/Sales/EngagementTable.vue) (`payment-column`) · [StatusCell.vue](/resources/js/Components/Sales/StatusCell.vue) (`hide-payment`) · [SectionTabs.vue](/resources/js/Components/SectionTabs.vue) (`employee-performance`, `permissionAll`) · [ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) (`ceoNav`: Employee Performance, My Videos)

**Migrations** — `2026_09_28_190000_add_zoom_closer_id_to_engagements_table` · `190100_create_performance_team_members_table` (seeds the teams by email) · `190200_add_is_property_sales_to_zoom_webinars_table` · `190300_create_video_productions_tables` · `190400_create_log_video_productions_permission`

**Routes** (`routes/web.php`, CEO group) — `manage.ceo.employee-performance.property-closing.{index,export}` · `.sales-engagement.index` · `.teams.update` (`PUT teams/{team}`) · `.webinars.update` (`PUT webinars`, a list); in their own group gated `view-ceo-dashboard|log-video-productions`: `.video-editor.index` · `.videos.{store,update,destroy}`; and `manage.engagements.zoom-closer` (`PUT /manage/engagements/{id}/zoom-closer`).

**Tests** — [PropertyClosingTest](/tests/Feature/Ceo/PropertyClosingTest.php) (paid membership rule, payment order, Zoom closer filter, export, gates) · [SalesEngagementReportTest](/tests/Feature/Ceo/SalesEngagementReportTest.php) (meaningful, the after-the-talk rule, marked webinars only, sales Zoom after payment) · [EmployeePerformanceWritesTest](/tests/Feature/Ceo/EmployeePerformanceWritesTest.php) (editor sees/edits only her own, team pools, webinar flag, Zoom closer).
