# Calendar / Timetable (Manage)

**Portal:** Manage · **Routes:** `manage.calendar.*` (`index` + `zoom-meetings.{hosts,store,update,destroy,cancel,start,invitation}` + `activities.{store,update,destroy}`, gated `permission:view-calendar` on the group, `manage-calendar` on every write). Appointment writes are **not** calendar routes — they belong to the Appointment module (`manage.appointment.appointments.{store,update,destroy,outcome}` under `/manage/appointments` — `outcome` is the `PATCH {uuid}/outcome` one-click endpoint the calendar's own detail modal calls, see [Recording an outcome in one click](#recording-an-outcome-in-one-click) — gated `view-appointments` / `manage-appointments`), and the calendar merely posts to them. · **Nav:** Operations suite → **Dashboard → Calendar** (a `DashboardTabs` tab beside Insights / Sales / Pipeline, so the sidebar reads "Dashboard" here); the project and subsale suites keep their own "Calendar" entry

## What it does
A Google-Calendar-style **timetable** in the admin portal, with a **Month | Summary** toggle (`?view=month|summary`). Month shows a **month grid**; clicking a day opens a right-side **drawer** listing that day's events, with a **Create** button that opens the shared **`AppointmentFormModal`** prefilled to that day at 09:00. Summary is a flat, range-based list grouped by day (see [The Summary view](#the-summary-view) below) — same events, same detail modal, just a different shape for reviewing "what happened last week" instead of paging through weeks. There is no "what kind of event?" first step on create: the modal's **Type** dropdown carries the four appointment types *plus* two pseudo-types, **"Zoom meeting"** and **"Activity"**, and picking either morphs the same form into that layout. New events appear in the drawer/grid/Summary immediately. Clicking an existing event opens a **detail modal** whose actions depend on the event's `source`:
- **Owned appointment** — read-only detail (type / status / outcome / project / location / notes / outcome notes / lead), **Edit** (hands back to the shared `AppointmentFormModal`, never an inline form) + **Delete** (`ConfirmModal`).
- **Calendar-scheduled Zoom meeting** (`lead_id` null, owned) — **Start / Edit (reschedule) / Cancel / Delete**, plus **Copy meeting invitation** while upcoming.
- **Lead-scheduled Zoom meeting** — read-only with an **Open in Lead** button, plus **Copy meeting invitation** while upcoming.
- **Funnel session** — always read-only (no owner): mode + status badges, funnel/series names, description, time range, and a conditional **Open session** link.
- **Activity, owned or assigned** — read-only detail (time / details / owner / assignee list), **Edit** (same shared `AppointmentFormModal`, via the Activity pseudo-type) + **Delete**, for the owner **or any assignee**.

The calendar merges **four sources** into one normalized `events` stream (`CalendarController::index`, one query per source, concatenated and sorted by `start_at`):
- **Appointments** (`Src\Appointment\Appointment`) — a real sales meeting with a lead: Showroom Visit / Video Call / Phone Call / Site Visit, each carrying **two independent facts** — a lifecycle `status` (Scheduled / Confirmed / Cancelled) and an `outcome` (Attended / No Show / Follow-up Needed / Closed / Not Closed, `NULL` until someone records one) — plus an optional `project_id` and `outcome_notes`. See [Status vs outcome](#appointments-carry-two-facts-status-lifecycle-and-outcome-what-happened) below. The calendar **renders and posts** them; the module that **owns** them is Appointments (`Src\Appointment` + `app/Http/Controllers/Manage/Appointment/AppointmentsController.php` — it has no handbook folder of its own yet, so this doc is the closest thing to one).
- **Zoom meetings** (`Src\Zoom\ZoomMeeting`) — two flavours, distinguished by `lead_id`:
  - **Calendar-scheduled** (`lead_id` null) — created **here** via the account-level S2S Zoom API by any **Zoom account user**, and fully manageable here (start / reschedule / cancel / delete) by the owner.
  - **Lead-scheduled** (`lead_id` set) — the admin's own meetings scheduled from Lead pages **or attached to a lead at calendar-creation time** (see "Optionally attach a lead" below), merged in read-only for management (they belong to the [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) flow); only the shareable **invitation** is exposed here.
- **Funnel sessions** (`Src\Event\Event`) — org-wide, read-only, **always merged in both scopes with no permission gate** (§[Sessions](#sessions--the-org-wide-read-only-third-source) below). *Why no gate*: the sales floor plans its week around these sessions the same way it plans around its own meetings, so hiding them behind `view-events` would defeat the point of putting them on the personal calendar at all — only the deep-link **into** the Events page is permission-gated, so a viewer without that access sees the session but never a link that 403s.
- **Activities** (`Src\Calendar\Activity`) — a lightweight, generic calendar entry with **multi-admin assignment**: title, date, start time, optional end time + description, and a set of assignee admins who each see it on their own calendar and can edit/delete it alongside the owner. See [Activity: retired, then revived](#activity-retired-2026-06-then-revived-2026-08-27-with-multi-admin-assignment) below.

> **Calendar "Zoom meeting" creation uses the account-level Server-to-Server Zoom API.** Choosing **Zoom meeting** schedules a *real* meeting hosted on the **acting admin's own Zoom user** by default (their email must be a user on the connected Zoom account) — Zoom generates the `join_url`; nothing is pasted. A **Host** dropdown in the same form can instead hand the meeting to a colleague: it lists the staff the actor may assign (`assignableZoomHostOptions()`) who are also users on the Zoom account, read live from `accountUserEmails()` — so a person added to the Zoom account appears with nothing to configure per user. The chosen host becomes the meeting's owner (`admin_id`): it sits on **their** calendar, the lead's invite names them as organiser, and only they can start / reschedule / cancel it afterwards. This shares the same S2S integration as the Lead/Zoom flow (see the [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) doc). Picking **Activity** instead never touches Zoom at all — it is a plain database row.

### Activity: retired (2026-06), then revived (2026-08-27) with multi-admin assignment

⚠️ **If you remember this handbook saying Activity was "dead weight awaiting removal", that was true for about ten weeks and is no longer true.** Free-text calendar activities were originally retired in favour of Appointments (2026-06) — a meeting you can report on beats an untyped note — and for a while `Src\Calendar\Activity`, `ActivityRepository` and the `activities` table sat with zero callers: no `ActivitiesController`, no `activities.*` routes, `CalendarQueryRequest::$model` pointing at a class the request never actually queried.

`plan_doc/calendar-sessions-activities-summary.md` (ratified 2026-08-20, implemented through 2026-08-27) **deliberately reversed that decision**, reusing the same model/repository/table rather than building a new one — the columns this feature needs (`title`, `details`, `start_at`, `end_at`) already existed. What's new is entirely additive:
- A new pivot table, **`activity_admins`** — see [Assignment model](#assignment-model--the-activity_admins-pivot) below.
- A new `ActivitiesController` under `manage.calendar.activities.*`, its own Form Requests, and a new Notify event (`calendar.activity_assigned`).
- `Activity::toCalendarArray()` was rewritten in the process — a null viewer now means **NOT** editable (the opposite of what it used to do, a legacy inversion left over from a call site that no longer exists), matching the convention `Appointment::toCalendarArray()` and `Event::toCalendarArray()` both already use.

`CalendarQueryRequest::$model` pointing at `Activity::class` is therefore no longer "inert" — the calendar genuinely reads and writes `activities` rows again. The `TYPE_ZOOM` column/constant on `Activity` (a legacy pasted-Zoom-link kind, distinct from the real Zoom API integration) is untouched by the revival and still has no write path of its own; `ZoomMeeting::toCalendarArray()` still borrows its colour token so the sky chip colour has one definition.

### Attaching a lead — required for an appointment, optional for a Zoom meeting, absent for an Activity
`AppointmentForm.vue` puts a searchable **lead field** at the very **top** of the form, above Type/Topic, in both of its Appointment/Zoom shapes — its own debounced typeahead against `GET /manage/leads/search`, not the shared `ComboBox` (see the Frontend note). The chosen `lead_uuid` is always resolved to `lead_id` **server-side**; a client-supplied `lead_id` is never trusted. The three shapes differ, and the difference is the point:
- For an **Appointment** the lead is **mandatory** — `Appointments\StoreRequest` rules it `['required','string','exists:leads,uuid']`, and the form marks it with a red asterisk. An appointment *is* a meeting with somebody; a lead-less one would be un-reportable. Its detail modal shows the lead with an **Open in Lead** button.
- For a **Zoom meeting** the lead is **optional**, and supplying one sets `zoom_meetings.lead_id`, which **promotes the meeting to the lead-scheduled flavour** — it then appears on the lead's page and is read-only on the calendar (managed in the [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) flow). Any **Zoom account user** can attach a lead (Zoom access is account-user based, not position based).
- An **Activity has no lead field at all** — it is a team-coordination tool, not a customer-facing meeting. The Activity branch of `AppointmentForm.vue` hides the Lead field entirely (`v-if="!isActivity"`) rather than rendering an unused optional one.

## Audience & ownership
- **Super Admin + Admin only.** The `manage` route group middleware (`auth` + `admin` ⇒ `User::isAdmin()`) is the floor — Members/Non-Members are main-portal users and never reach it. On top of that the calendar group itself carries **`permission:view-calendar`**, and every Zoom/Activity write inside it carries **`permission:manage-calendar`**; appointment writes live under `/manage/appointments` with `view-appointments` / `manage-appointments`. *(The earlier "no extra middleware" note is obsolete — the permission layer landed with `Src\Auth\Permission`'s Calendar/Appointments groups.)*
- **Owner-scoped reads, with an all-view toggle for Super Admin OR Sales Leader (widened 2026-08-27).** A normal Admin only ever sees and manages their **own** events (plus activities they're **assigned** to); each owner-scoped source filters in **its own owner space** (appointments `where('created_by', $userId)`, zoom meetings `where('admin_id', $adminId)`, activities `where('admin_id', $adminId) OR assigned`). **Funnel sessions are the exception** — org-wide and always visible regardless of scope; see [Sessions](#sessions--the-org-wide-read-only-third-source). The toggle was `Super Admin` only through 2026-08-25; `User::isSalesLeader()` (`hasRole(Role::SALES_LEADER)`) widened the gate to `isSuperAdmin() || isSalesLeader()` — one line, and the coercion / `canViewAll` prop / view-only stamping of others' events all follow automatically. In `?scope=all` the owner filter is dropped on every owner-scoped source so every admin's events render, but other admins' events come back **view-only** (`editable=false`, `can_manage=false`) and each carries an **`owner_name`** (shown as a per-owner accent chip) so they're distinguishable. The scope gate is **server-side** — `index` honours `?scope=all` only for that pair of roles; any other value, or a normal Admin tampering with the query, is silently coerced to `mine`. Applies identically to **both** the month grid and the Summary view — one gate, read once per request.
  > ⚠️ **Ratified, not an oversight: a Sales Leader in `all` scope sees every admin's appointments, including lead names and outcome data.** The calendar has never applied `LeadVisibility` (the module-wide rule that scopes which leads a given admin may see) to this surface, and this widening does not add it. Recorded in `plan_doc/calendar-sessions-activities-summary.md` Decisions #4 specifically so a future reader treats it as a deliberate trade-off — team leads reviewing the floor's week needs the names to mean anything — rather than a hole to quietly patch.
- **Writes are never widened.** Every write stamps its owner column server-side and re-checks ownership (`abort 403` on mismatch) — there is **no** Super-Admin/Sales-Leader write override (a viewer in "All" scope still cannot edit/delete another admin's event, and an Activity's ownership rules are their own — see [Assignment model](#assignment-model--the-activity_admins-pivot)). The Vue UI hiding actions is never the only control.
- **Zoom scheduling needs a Zoom account user.** Scheduling/managing a calendar Zoom meeting requires the acting admin to be a **user on the connected Zoom account** — `CalendarZoomMeetingsController` re-checks `ZoomServerService::isAccountUser($email)` (+ meeting ownership) on every action. A chosen **host** is an additional gate, never a replacement: `store` re-derives the actor's host pool server-side and refuses a `host_uuid` outside it, then refuses a host who is not a Zoom account user — both before any Zoom call. The `GET zoom-meetings/hosts` list that fills the dropdown carries the same `manage-calendar` permission as `store` and is never the authorization control. There is **no** `zoom` route middleware and **no** Closer/position gate (Zoom access is account-user based, not position based). The `zoom.connected` index prop — true when the acting admin is a Zoom account user — only decides whether the Vue **Create → Zoom meeting** choice is offered; it is never the authorization control. Attaching a lead is open to any Zoom account user. **Activities have no such gate** — any admin holding `manage-calendar` may create one.

## How it works

### Two owner keys, and the seeded-Super-Admin gap
⚠️ **The merged stream has two different owner spaces**, and conflating them is the easy bug here (sessions have no owner at all; see [Sessions](#sessions--the-org-wide-read-only-third-source)):

| Source | Owner column | Id space | Viewer id passed to `toCalendarArray()` |
|---|---|---|---|
| `appointments` | `created_by` | `users.id` | `$userId` |
| `zoom_meetings` | `admin_id` | `admins.id` | `$adminId` |
| `activities` | `admin_id` | `admins.id` | `$adminId` |

Each collection is stamped with the viewer id **for its own space**, so `editable` is right on all three. Appointments deliberately key on the *user* — the person who booked the meeting — so they need no `admins` row at all. Activities key on `admins.id`, the SAME space `zoom_meetings.admin_id` already uses — including the `activity_admins` assignment pivot (see [Assignment model](#assignment-model--the-activity_admins-pivot)). ⚠️ **This is NOT the convention `engagement_assignments.admin_id` uses — that column is `users.id` despite its name.** Copying that pattern here would silently corrupt every assignee lookup; `activity_admins.admin_id` and `activities.admin_id` are the one pairing on this page that is genuinely `admins.id` end to end.

The Zoom/Activity half still needs an admin row, and the three seeded Super Admins are created with **no** admin block, so `$user->admin` is `null` for them. Resolved by provisioning the row **on the write path only**:
- **Zoom writes** call **`UserRepository::ensureAdmin($user)`** — a transactional `lockForUpdate()` + `withTrashed()` *find-or-restore-or-create* (not `firstOrCreate`, which would race or collide with a soft-deleted row). This provisions the row the moment a seeded Super Admin first schedules a meeting, and is idempotent.
- **The index GET stays read-only:** it reads `$request->user()->admin?->id` and falls back to `0` — deliberately **not** calling `ensureAdmin`, because a GET must not write. `adminId = 0` matches no `zoom_meetings` row, so such a user simply sees no Zoom events (their appointments still render, since those key on the user id). No `403`, no side-effecting GET.

This required a **unique index on `admins.user_id`** (added in a separate new migration; the column was previously only plain-indexed) as the DB backstop so concurrent first-touches can't insert duplicate admin rows. A unique index is not a schema FK constraint, so it is consistent with GUIDELINES §7.

### Reads — the merged payload (`CalendarController@index`)
Read-only, and the **one** method serving both the month grid and the Summary view — the only thing that differs between them is the `[$from, $to]` window (see [The Summary view](#the-summary-view) below); every query, eager-load and scope rule described here applies identically to both.

Resolves the month from `?month=YYYY-MM` (guarded by `Carbon::createFromFormat`, falling back to the current month so a malformed value can't 500), the page view from `?view=month|summary` (anything but the literal `summary` coerces to `month`), and the scope from `?scope=mine|all` — `all` is honoured **only** for `isSuperAdmin() || isSalesLeader()`; any other value (or a normal Admin tampering) is coerced to `mine`. In Month view it pads the month to full **Mon–Sun** weeks; in Summary it uses the resolved date range as-is. It then queries, against that one shared window:
- **`appointments`** (by `scheduled_at`) — `mine` filters `created_by = $userId`; `all` drops the filter. Eager-loads `lead.user.profile`, `project.catalogProject`, `createdByUser.profile`.
- **`zoom_meetings`** (by `start_time`) — `mine` filters `admin_id = $adminId`; `all` drops the filter (and additionally eager-loads `agent.user.profile`). Eager-loads `lead.user.profile`.
- **`sessions`** (`Src\Event\Event`, by `scheduled_date`) — **no scope filter at all**, in either `mine` or `all`; see [Sessions](#sessions--the-org-wide-read-only-third-source). Eager-loads `series`, `funnel`, `webinar`.
- **`activities`** — `mine` filters `admin_id = $adminId` **OR** an `assignees` `whereHas` on the same id; `all` drops the filter. Eager-loads `admin.user.profile`, `lead.user.profile`, `assignees.user.profile`.

All four collections are normalized through **model methods** — `Appointment::toCalendarArray($userId)`, `ZoomMeeting::toCalendarArray($adminId, $canManageZoom)`, `Event::toCalendarArray($canOpenSessions)`, `Activity::toCalendarArray($adminId)` — where the viewer id drives the per-event `editable` / `is_owner` / `can_manage` / `can_invite` / `can_delete` flags (so in `all` scope **other admins' events come back view-only**; sessions are always `editable: false`), never mapped inline — concatenated, sorted by `start_at`, and returned to `Inertia::render('Manage/Calendar/Index')` with `month`, `view`, `dateFrom`/`dateTo` (always resolved and echoed, in **both** views — see the Summary section for why), `events`, **`appointmentTypes` + `appointmentStatuses` + `appointmentOutcomes`** (`Appointment::TYPES` / `::STATUSES` / `::OUTCOMES`, so badges/colours are never hardcoded), **`projects`** (canonical-name project list for the appointment form), **`scope` + `canViewAll`** (echoed so the toggle re-renders; `canViewAll = isSuperAdmin() || isSalesLeader()`), `zoom: { connected }` (true when the acting admin is a **user on the connected Zoom account** — gates the Create → Zoom-meeting choice), and **`assignableAdmins`** (`[{uuid, name, email, phone, sublabel}]`, the Activity assignee picker's option pool). `$canManageZoom = $user->can(MANAGE_ZOOM)` is passed into the Zoom mapper because managing a **lead** meeting from the calendar routes to the `MANAGE_ZOOM`-gated Lead/Zoom endpoints — the flag decides whether those actions are offered at all. Month nav re-requests `index` with a new `?month=` **(carrying `?scope=` so the chosen scope survives prev/next)** via Inertia.

### Sessions — the org-wide, read-only third source

Funnel sessions (`Src\Event\Event`, the module the [Events/Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) handbook owns) merge into the calendar **unconditionally, in both scopes, with no permission gate** — cancelled sessions included, no status filter at all. This is deliberate, not a hole: **the sales floor plans its week around these sessions the same way it plans around its own appointments**, so gating the calendar row on `view-events` would defeat the entire point of putting company sessions on a personal timetable. What IS gated is the deep link **into** the Events page: `can_open` (`$user->can(VIEW_EVENTS) || $user->can(VIEW_EVENTS_REPORTS)`, `can()` because it resolves permission implication, unlike a role check) decides whether "Open session" renders at all — a viewer without that access sees the full session detail but never a link that would 403 the moment they clicked it.

`Event::toCalendarArray(bool $canOpenSessionPage)` emits `source: 'session'`, `type_color: 'violet'` (already a live token in every local colour map — zero chip churn), **both** the stored `status`/`status_label`/`status_color` triplet (Scheduled/Completed/Cancelled) and the DERIVED `live_status`/`live_status_label`/`live_status_color` triplet (Upcoming/Live/Past/Cancelled — a Zoom session follows its webinar's live webhook-driven status, everything else falls back to the clock), `mode`/`mode_label`, `funnel_uuid`/`funnel_name`, `series_name`, `location`, `details`, `start_at`/`end_at` (via the existing `startsAt()`/`endsAt()` helpers, same KL convention as every other source), `can_open`, and hardcoded `editable: false` / `owner_name: null` — a session has no owner, so unlike appointments/zoom/activities there is nothing to key an owner space off at all.

⚠️ **Sessions use `red` for their Cancelled colour token** (`Event::STATUSES`/`LIVE_STATUSES`), which no other calendar source ever sent before this merge. See [the colour-token inventory](#colour-token-inventory--every-local-map-must-carry-every-token) below — every local `badgeColor()`/`dotColor()` map on this page had to add it, or a cancelled session renders a blank badge on that one surface while looking fine everywhere else.

### The Summary view

A **Month | Summary** toggle (`Index.vue`, precedent: `SalesProjects/Partials/PropertyMatchView.vue`'s `List | Calendar` pills) swaps the month grid for a flat, range-based list grouped by day (`Partials/SummaryList.vue`) — the answer to "what happened last week", which a month grid genuinely cannot show without paging back and forth. It is **not a second controller or a second query** — see [Reads](#reads--the-merged-payload-calendarcontrollerindex) above; `?view=summary` only changes which `[$from, $to]` window the same four source queries run against.

- **Range resolution** (`?date_from`/`?date_to`, `Y-m-d`): defaults to **today−6 → today** (7 days inclusive — the same shape `FilterDrawer`'s `7d` preset uses). A malformed value on **either** side falls back to the **whole** default range (mixing a guessed bound with a garbage one is worse than a clean reset); an **empty** side (missing, or the user cleared just one date input) keeps whatever the other side supplied and only defaults the empty one. `from > to` is swapped rather than rejected. The range is resolved and echoed **even in Month view**, so switching into Summary always has a sane default without a second round trip, and toggling back to Month is never the reason the range resets.
- **The 92-day cap (ratified 2026-08-25, Han's Decisions #5), INCLUSIVE of both endpoints.** An uncapped `?scope=all` range spanning years would be an unbounded org-wide query across all four sources. `diffInDays()` counts the GAP between two dates, not the days spanned, so an N-day-inclusive window is `diffInDays() === N - 1` — exactly how the 7-day default is built via `subDays(6)`, not `subDays(7)`; the cap pulls `from` forward (not `to`) when a hand-picked range exceeds it, since `to` is the more recently touched bound in most flows. Every preset sits well under it; only a manually typed range beyond ~3 months is ever clamped.
- **`Partials/SummaryRangeBar.vue`** — preset pills (Today / Last 7 days / Last 30 days / This month) plus two cross-clamped `<input type="date">`. ⚠️ **Its date math deliberately does NOT reuse `FilterDrawer.vue`'s preset arithmetic**, even though the plan named that file as the precedent to lift from — `FilterDrawer` anchors every preset on the **browser's own** `new Date()`, which is fine for a plain "created date" filter with no server-side timezone contract. The calendar is the one screen on this page with such a contract (`CalendarController::index` resolves the Summary default in Asia/KL), so a browser sitting on UTC at 07:00 KL would otherwise float its "Today"/"Last 7 days" pills a day behind the server's idea of today — no pill highlighting despite the page sitting exactly on that preset, and clicking "Today" requesting yesterday. `todayKlKey()` (from `useKlTime()`, the same source `Index.vue`'s own `navigateToday()` uses) anchors every preset bound to the **server's** calendar day instead, and all the arithmetic runs through UTC-epoch `Date` objects (`Date.UTC(...)` / `getUTCFullYear()` etc.) so the browser's own timezone is never consulted for a date-only calculation. Applying a preset or editing a date input emits `change` immediately — the page navigates with `preserveState: false`, so range state lives entirely in the server-echoed `dateFrom`/`dateTo` props, never a local ref that a half-typed value could lose.
- **`Partials/SummaryList.vue`** — groups the shared `events` prop by `toKlDateKey(start_at)` into sorted day sections, each row's content chosen by `source`: an appointment shows its type chip, phase badge, outcome badge (absent when `null`, never an "unknown" chip) and a lead button opening the shared `LeadDetailModal.vue` in place; a Zoom meeting shows its status badge + lead; a session shows mode + live-status badges, funnel name, and a conditional "Open session" link; an activity shows the owner + assignee names (read-only — create/edit/delete for an activity live in the shared form modal and the detail modal, never in this row). A row click emits the SAME `open-detail` the month grid's chips use, so one detail modal serves both views.

### Writes — appointments (`Manage\Appointment\AppointmentsController`)
⚠️ **The calendar has no appointment write routes of its own.** `CalendarController` never writes — it only renders. The Create/Edit/Delete affordances on the grid post to the **Appointment module's** endpoints (`POST /manage/appointments`, `PUT|DELETE /manage/appointments/{uuid}`, named `manage.appointment.appointments.*`), gated `permission:view-appointments` on the group and `manage-appointments` on each write, and they all `back()` — so the same endpoint serves the calendar, the Lead page and the Appointments list.

Ownership is **owner-only on `created_by`**: `update` and `destroy` both `abort_if((int) $appointment->created_by !== (int) $request->user()->id, 403)`. There is no Super-Admin override — a Super Admin in `?scope=all` can *see* another admin's appointment but gets a 403 on any write, which is why `toCalendarArray()` returns `editable=false` for it.

The full write path — validation rules, the repository — belongs to the Appointment module (`app/Http/Controllers/Manage/Appointment/`, `app/Http/Requests/Manage/Appointment/Appointments/`, `src/Appointment/`); this doc only covers how they reach the grid. ⚠️ **`appointments.engagement_id` is not part of that write path.** Neither controller maps it and `AppointmentRepository`'s `data_only()` whitelists deliberately omit it; its only writer is the one-off [`2026_07_14_100004`](/database/migrations/2026_07_14_100004_backfill_engagements_from_appointments.php) backfill. The `engagement()` relation reads it. Do not "restore" a live link that never existed. ⚠️ Appointments have **no handbook folder of their own yet** — do not link `manage/appointments/readMe.md`, it does not exist.

> This section used to say the `ActivitiesController` writing `Src\Calendar\Activity` rows, its Form Requests and the `activities.*` routes were all deleted when Appointments replaced generic activities. That was true from 2026-06 to 2026-08-27 — the revival (see [Activity: retired, then revived](#activity-retired-2026-06-then-revived-2026-08-27-with-multi-admin-assignment)) brought all three back, described in the next section.

### Writes — calendar Activities (`Manage\Calendar\ActivitiesController`)

`store` / `update` / `destroy` under `manage.calendar.activities.*`, all gated `permission:manage-calendar`. Thin controller, explicit field mapping, delegates every write to `ActivityRepository` inside `DB::transaction()`.

- **`store`** — `UserRepository::ensureAdmin($user)` provisions the actor's admin row on the write path (a seeded Super Admin has none); maps `title`/`details`/`start_at`/`end_at` straight from the request; resolves `assignees` (uuids) to `admins.id`, provisioning an admin row for each newly-assigned admin too (same idempotent `ensureAdmin()`, same write-path-only rule the Zoom half already follows); `flash()` + `back()`.
- **`update`** — `Activity::with('assignees.user')->where('uuid', $id)->firstOrFail()`; the write gate is **owner OR any assignee** (`abort_unless`, never provisioning — a user with no admin row simply fails the check). Scalar fields (`title`/`details`/`start_at`/`end_at`) are **full-replace on every write**, same convention `AppointmentsController@update` uses for `location`/`notes` — an omitted field is written as whatever the controller mapped it to, not left alone.
- **`destroy`** — same owner-or-assignee gate; soft-deletes via the repository.

⚠️ **ANYONE on the activity — owner or any assignee — may change the assignee roster.** This is the **current** rule (tech lead decision, 2026-08-27) and it **superseded a same-day owner-only rule** ("Han's ratified decision") that briefly required a non-owner's `assignees` key to be silently ignored. That silent-ignore behaviour, `ActivitiesController::isOwner()`'s roster-change guard, and `Activities\UpdateRequest`'s own separate `isOwner()` copy that used to drop the `assignees` validation rules for a non-owner are all **gone** — adding/removing an assignee is now just another field, exactly like `title`/`details`/`start_at`/`end_at`, and `assignees` validates identically regardless of who is editing. `ActivitiesController::isOwner()` still exists (the owner-or-assignee write gate on `update`/`destroy` still needs it), but it is no longer involved in the roster decision at all.
⚠️ **The trade-off the owner-only rule was managing did not go away — it got WIDER.** The notification (below) only fires for **newly-added** assignees, so a REMOVAL is still silent for everyone: dropping a colleague off the roster carries no signal anywhere, and now *any* editor — not only the owner — can trigger that silence.

⚠️ **The `assignees` key is ABSENT-vs-PRESENT-EMPTY sensitive, and this is the bug that actually shipped and was caught.** `ActivityRepository::update()` originally collapsed both cases to the same `sync([])` call via `$data['activity']['assignee_admin_ids'] ?? []` — meaning ANY partial update that didn't mention `assignees` at all (e.g. one only touching `title`) silently wiped every assignee off the activity. The fix, now the rule everywhere on this write path:
- **Key ABSENT** from the request ⇒ the existing assignment set is left **completely untouched** — no `sync()` call happens at all. Checked via `array_key_exists()` at the repository layer and `$request->has('assignees')` at the controller layer — never `?? []`, which cannot tell "omitted" from "sent empty".
- **Key PRESENT, even as `[]` or `null`** (the global `ConvertEmptyStringsToNull` middleware turns an empty form field into `null`) ⇒ this **DOES** clear the roster — a deliberate "assign nobody", `sync([])`. `(array)` casting normalises `null` to `[]` here without losing the presence check that gated whether to run it at all.

Both rules are pinned in `ActivityCrudTest` — `testUpdateWithoutAssigneesKeyLeavesAssignmentsIntact` and `testUpdateWithEmptyAssigneesArrayClearsAssignments` are the two tests that stop a future "simplification" from reintroducing the wipe.

⚠️ **Assignee validation is bounded to the SAME assignable-staff pool the picker offers, never `exists:users,uuid`.** `Activities\StoreRequest::rules()` validates `assignees.*` with `Rule::in($assignable)` against `assignableStaffQuery()` — the identical trap already fixed once on `CallerRotationRequest`/`VslConsultationRequest` (see the [funnels handbook](/docs/modules_handbook/manage/events/funnels/readMe.md)'s `caller_uuid` note): **every customer and every lead is a `users` row too**, so an `exists:users,uuid` rule (what the plan's own spec literally called for, before this was caught) would have accepted a customer's uuid as a colleague's assignee — `resolveAssigneeAdminIds()` would then have provisioned them an `admins` row and flipped their lead's `is_staff` flag. `Rule::in()` against the bounded pool also closes a group-scoping bypass the existence rule would have missed: one group's admin could otherwise assign another group's admin, someone the picker would never actually offer. `UpdateRequest::assignableUuids()` additionally **unions in the activity's current assignees**, so someone who has since gone inactive or changed group never blocks an edit that doesn't even touch the roster — whoever is editing (owner or any assignee, now that anyone may) must never fail validation over a person they never touched.

### Assignment model — the `activity_admins` pivot

`Activity::assignees(): BelongsToMany` → `belongsToMany(Admin::class, 'activity_admins', 'activity_id', 'admin_id')->withTimestamps()`. No pivot model — `sync()` and `whereHas()` off the relation are the only access pattern this feature needs. `editable` in `toCalendarArray()` is true for the **owner OR any assignee**; a `null` viewer (no context) is **never** editable — this is the one place the revival changed inherited behaviour rather than just adding to it (see [Activity: retired, then revived](#activity-retired-2026-06-then-revived-2026-08-27-with-multi-admin-assignment)).

The assignee picker's pool comes from `ResolvesAssignableManagers::assignableAdminOptions()` — the same active + manage-role + group-scoped base query `assignableManagerQuery()` uses for lead assignment, but **without** the `SALES_EXECUTION` filter: an activity is a general team-coordination tool, not a sales-specific one, so a non-sales manage-role admin (marketing, ops) must still be assignable. Shipped as the `assignableAdmins` Inertia prop, consumed by a **local-options** `ComboBox` (no search endpoint — the pool is small enough to ship as a prop).

### Notification — `calendar.activity_assigned`

Assigning someone to an activity buzzes their own Telegram chat via the Notify service (`Src\Common\Notify`) — the addressed `sendToUsers()` path, same family as `leads.action_item_assigned` and `conversation.review_received`. `app/Http/Controllers/Concerns/NotifiesActivityAssignees.php` (mirroring `NotifiesVslCaller`) builds the message and calls `Notifier::sendToUsers('calendar.activity_assigned', $message, $userIds)` **after** the repository's transaction has committed — fire-and-forget, never wrapped in try/catch, never checking the return (`Notifier` contractually never throws).

⚠️ **`sendToUsers()` takes `users.id`, never `admins.id`.** Assignee ids arrive from the controller in the `admins.id` space (see [Assignment model](#assignment-model--the-activity_admins-pivot)); the trait hops `Admin::whereIn('id', $assigneeAdminIds)->pluck('user_id')` — the same hop `RecordingReviewsController::notifyOwner()` makes for `conversation.review_received`.

Semantics are asymmetric by design:
- **Create** — every assignee except the actor. A brand-new roster has no "before" to diff against, so everyone on it counts as newly added.
- **Update** — only the assignees this write **actually added**, diffed by the controller against the roster as it stood **before** the write (captured right after the ownership/assignee gate, before the repository call re-syncs the pivot — taking that snapshot even one line later would diff the refreshed instance against itself and send nothing). Someone already on the roster is never re-buzzed because an unrelated field changed. This fires identically regardless of whether the editor is the owner or an assignee — anyone on the activity may change the roster (see above), so this path is no longer owner-exclusive.
- A plain edit with no roster change sends **zero** notifications. A REMOVAL also sends zero — nothing notifies the person dropped, or anyone else — which is the trade-off called out above, now reachable by any editor rather than only the owner.

⚠️ **The throttle is `0`, and a non-zero default was tried and reverted.** `Notifier::dispatch()` claims its throttle window **once per `sendToUsers()` call**, keyed on `event + sha1(scope)`, **before** resolving destinations — so a per-entity scope (`throttleScope('activity:' . $activity->id)`) only protects against a second, DIFFERENT entity being throttled together with this one; it does nothing to stop a SECOND EVENT on the SAME entity from sharing one window. An owner creating an activity (assigning A) and immediately editing it to add B is the *ordinary* case, not a rare one — a 60-second window (the plan's own original spec) silently dropped B's notification, with nothing but a `SKIP_THROTTLED` `notify_deliveries` row to explain it, and the window was claimed even when the first send had no reachable destination at all. Every other **addressed assignment** event in the registry (`leads.action_item_assigned`, `conversation.review_received`, `payment.claim_submitted`) already uses `0` for exactly this reason; `events.vsl_caller_assigned`'s `60` is not a counter-example, because a consultation is assigned a caller exactly once, whereas an activity roster is mutable. The `NOTIFY_ACTIVITY_ASSIGNED_THROTTLE_SECONDS` env override still exists for an operator who deliberately wants a window — set it and `throttleScope` then mutes only that ONE activity, never the event globally.

⚠️ **Existing personal destinations needed a backfill migration, not just the registry entry.** `default: true` in `config/notify.php` only pre-ticks the checkbox for a destination created AFTER the event exists (`subscribeDefaults()` runs exactly once, on destination creation) — every admin who already had a personal Telegram chat before this event shipped would otherwise never see it ticked and receive nothing, looking like a broken feature while behaving exactly as designed. `2026_08_27_100002_backfill_activity_assigned_subscriptions.php` is a line-for-line sibling of the `conversation.review_received` backfill: personal (non-shared), not-soft-deleted destinations only, insert only where no `(destination_id, event_key)` row exists yet, `down()` deletes by event key.

### Appointments carry two facts: `status` (lifecycle) and `outcome` (what happened)

`appointments.status` used to be a single 7-value enum that conflated the two, so recording that a lead turned up **erased** the lifecycle fact — and "attended, needs follow-up", the most common real outcome, could not be expressed at all. They are now two columns:

| Column | Values | What it says |
|---|---|---|
| `status` | Scheduled (1) · Confirmed (2) · Cancelled (7) | the scheduling lifecycle — is this meeting on? |
| `outcome` | `NULL` · Attended (1) · No Show (2) · Follow-up Needed (3) · Closed (4) · Not Closed (5) | what actually happened at it |

#### The badge is a THIRD, derived thing — `phase`

Neither column is what the UI actually renders. Surfaces show **`phase`**, computed on the fly from `(status, outcome, scheduled_at, now)` — there is **no `phase` column and deliberately no job that writes one**. Resolved top-down, **first match wins**:

| # | Condition | Phase | Label | Colour |
|---|---|---|---|---|
| 1 | `status = Cancelled` | `cancelled` | Cancelled | `slate` |
| 2 | `outcome` is set | `done` | Done | `slate` |
| 3 | `scheduled_at` is in the future | `upcoming` | **the stored status label** — Scheduled / Confirmed | `brand` / `indigo` |
| 4 | within `ONGOING_WINDOW_MINUTES` (60) of the start | `ongoing` | Ongoing | `violet` |
| 5 | otherwise (past, no outcome) | `awaiting_outcome` | Awaiting outcome | `amber` |

**Why derived and not stored.** `Ongoing` and `Done` are both already implied by data we hold. Storing them would need a sweep job (wrong in the gap between the clock passing and the job running, and stale the moment anyone reschedules) and would put one fact in two columns — `done` *is* "an outcome exists". That is precisely the conflation this section describes removing from `status`; do not reintroduce it one layer up. The cost, accepted: **phase cannot be used in a SQL `WHERE`/`ORDER BY`**. Nothing sorts or filters appointments by status today; when something needs to, the equivalent is `scheduled_at < now AND outcome IS NULL AND status != CANCELLED`.

**Order is the design, not an implementation detail.** Cancelled outranks everything so a called-off meeting whose time passes never reads as *Ongoing*; an outcome outranks timing so a row recorded years ago reads *Done*, not *Ongoing*. Row 3 defers to the stored label rather than inventing an "Upcoming" word — that is what `Confirmed` is for, and why keeping it costs nothing.

⚠️ **Render `phase_label`/`phase_color`, never `status_label`/`status_color`.** A surface still showing the raw status displays "Scheduled" on a meeting that happened last week. `status` is still in every payload, but **only** so the edit form can seed its dropdown. The form says so in a hint line, because "I set Scheduled but the badge says Awaiting outcome" otherwise reads as a bug.

⚠️ **`ONGOING_WINDOW_MINUTES` is an assumption, not a fact.** Appointments store only `scheduled_at` — there is no duration or end column — so nothing distinguishes a 30-minute call from a 3-hour site visit, and a long meeting reads *Awaiting outcome* while it is still running. Ratified 2026-07-31 as good enough; a real `duration` column is the fix if it ever bites. The post-window label is the neutral *Awaiting outcome*, never *Overdue*, precisely because the window may be wrong.

**`outcome = NULL` is a state, not a missing value.** It means "the appointment hasn't happened yet, or nobody has recorded it" — distinct from every outcome, which is why the column has no DB default. Every surface must render it as *absent* (no badge, no tint) rather than falling back to the status or inventing an "unknown" value. *Attended* is deliberately **`indigo`, not `emerald`**: `emerald` is reserved for *Closed*, the one outcome that means money, so the two are not indistinguishable when both badges sit in the same row. Outcome badges render on the calendar's detail modal, the Lead page's **Appointments** tab (whose timeline dot tints from `outcome_color` and falls back to `phase_color`, so an overdue appointment tints amber for *Awaiting outcome* rather than brand for *Scheduled*) and its **Pipeline** tab.

⚠️ **Adding or recolouring an outcome _or a phase_ means editing every local colour map too.** Each of those surfaces resolves a colour token through its own full-string lookup (Tailwind cannot see `bg-${color}-100`), so a token missing from one renders a meaningless badge there while looking fine elsewhere. The full, current list of files/maps is the [colour-token inventory](#colour-token-inventory--every-local-map-must-carry-every-token) below — the count is deliberately not repeated here, so it can't drift out of step with that table the way this exact sentence once did (it used to say "three files, four maps", missing `Calendar/Partials/SummaryList.vue`'s `badgeColor`, which the inventory table already listed correctly). Each map carries the rule as an inline comment — that is the copy to keep current; the inventory exists so someone adding a sixth outcome knows how many places to look. Phase 7 shipped with `indigo` and `rose` missing, which is exactly what this prevents. `AppointmentPhaseTest::testPhaseColoursStayWithinTheKnownBadgeTokens` pins the token *set* on the PHP side, so a phase colour outside it fails a test rather than rendering blank on one page — but it cannot know whether every Vue map actually carries each token, so **the manual check still matters**.

Recording an outcome does **not** touch `Engagement.status` — nothing syncs appointment → engagement in either direction.

#### Recording an outcome in one click

`PATCH /manage/appointments/{uuid}/outcome` (`manage.appointment.appointments.outcome`) backs a row of outcome buttons in **two** places: the calendar's detail modal and the Lead page's **Appointments** tab. Owner-gated like `update`/`destroy`; the buttons **toggle** — clicking the active one sends `null` and clears the outcome back to "not recorded", which is why `OutcomeRequest` uses `present` + `nullable` rather than `required` (`required` rejects null).

The buttons are hidden before the meeting (phase `upcoming` — nothing has happened yet), on a cancelled appointment, and for anyone who is not the creator (the `editable` flag, stamped per row by `LeadsController` exactly as `toCalendarArray()` does for the calendar — without it every admin holding `manage-appointments` was shown five live buttons on someone else's appointment, each of which 403s as a full-screen Inertia error).

⚠️ **`OUTCOME_PHASES` is the ONLY gate on recording early.** The edit form has no Outcome control and the endpoint does not check phase, so an `upcoming` appointment has no UI path to an outcome at all. That is intended. If a real "closed it before the meeting" case turns up, widen that list — do not put a second control back on the form.

⚠️ **The edit form has NO Outcome control** (removed 2026-07-31) — these buttons are the only way to set one, so the two can never disagree. Two consequences to keep in mind before "restoring" it:

- **Both surfaces must keep their button row.** The Lead tab's row is not a nicety: with the form field gone, removing it would leave the Lead page unable to record an outcome at all.
- **`form.outcome` is still hydrated and still submitted** by the edit form, just without a visible control. Dropping it from the payload would wipe the stored outcome on every edit, and the form's cancel-watcher (which clears the outcome when the status is switched to Cancelled) is what keeps a backfilled *Confirmed + Attended* row from violating the Cancelled rule.

⚠️ **The Edit button beside that row still gates on the `manage-appointments` permission alone** (`v-if="canManage"`), not on per-row ownership — so a non-owner's *Edit* click 403s at the endpoint instead of being hidden, even though the outcome buttons right next to it are correctly hidden (`canQuickOutcome` checks `a.editable`). Pre-existing limitation, not introduced here — and the flag it needs is already in the payload.

#### Keeping the calendar and the Lead page in step

An outcome recorded on one page leaves the other showing the old badge, because an Inertia page holds a snapshot of its props from when it loaded. This is **not** a storage problem — the write lands immediately and any page loaded afterwards is correct — it is that the browser has not re-asked. Two cases in particular: the **back button**, which Inertia serves from its history cache without contacting the server, and **two tabs open at once**, which have no channel between them.

[`composables/useRefreshOnFocus.js`](/resources/js/composables/useRefreshOnFocus.js) re-fetches a narrow set of props whenever the tab regains focus (`visibilitychange` for tab switches plus `focus` for app switches — either alone leaves a gap). Mounted on `Calendar/Index.vue` (`['events']`) and `Leads/Show.vue` (`['appointments', 'engagements']`). It uses `preserveState` so an open modal or half-typed form survives the refresh.

Focus rather than polling because you must focus a tab before you can read it, so it costs nothing while idle. The limit, accepted: pages agree **whenever you look**, not continuously — two tabs side by side still disagree until you click the stale one. Live sync means websockets, which this does not earn.

⚠️ **`only` filters the RESPONSE; it does not skip the work.** `LeadsController@show` builds every prop on that page (webinars, chats, recordings, wealth plans, AI conversations) before Inertia drops all but the two requested, so each refresh costs a full page render — which is why the Lead page passes a 30s floor instead of the 5s default. The real fix is deferring the heavy props into closures, which partial reloads *do* skip; until then, throttle. Do not add `useRefreshOnFocus` to another heavy page without checking this.

⚠️ **The calendar's detail modal must read its event from the LIVE `events` prop.** `Index.vue` keeps the clicked event in a `detailEvent` ref, which is a *snapshot*: after the PATCH refreshes `events`, the snapshot still holds the old outcome and the badge only updated when the modal was closed and reopened. `liveDetailEvent` re-looks the row up by uuid on every render (falling back to the snapshot when the uuid is gone, so a just-deleted appointment does not flash empty). Any future in-modal mutation depends on this.

⚠️ **This is a SECOND door to `outcome`, and it does not inherit the Cancelled rule.** `StoreRequest::withValidator()` cannot cover it — `status` is not in this payload — so `OutcomeRequest::withValidator()` restates it, reading the **stored** status via `$this->route('id')` (the same trick `UpdateRequest` uses). It lives in the Form Request, not the controller, per GUIDELINES §3/§8. Clearing (`null`) stays allowed even on a cancelled appointment: that moves a non-compliant row back into compliance, never away from it. Both halves are pinned in `AppointmentQuickOutcomeTest`.

Because a button row has no field to hang `form.errors` off, both surfaces catch the rejection in `onError` and render it beneath the buttons — otherwise a server-side refusal would be silent. Unreachable in normal use (the row is hidden on cancelled appointments); it exists so nothing fails quietly.

#### You cannot book into the past — but you must still be able to edit it

`StoreRequest` carries `after:now` on `scheduled_at` (and the calendar's Zoom `StoreRequest` has always had it on `start_time`). `after:now` resolves against the app timezone, Asia/KL, and `scheduled_at` is stored as KL wall-clock, so it compares like-for-like — do not "fix" it into a UTC instant.

⚠️ **`UpdateRequest` deliberately DROPS that rule.** Inheriting it would make every past appointment permanently un-editable — no fixing a typo, no adding notes, no recording an outcome on last month's meeting, which is most of what editing an old appointment is for. It instead rejects the past **only when the time actually moves**, comparing to the minute (the `datetime-local` input has no seconds, so a stored `14:30:00` round-trips as `14:30` and a second-precision comparison would read as "moved").

⚠️ **`UpdateRequest::withValidator()` MUST call `parent::withValidator()` first.** The parent carries the Cancelled × outcome rule; an override that forgets it silently switches that rule off for every edit. `AppointmentSchedulingWindowTest::testUpdateStillEnforcesTheCancelledOutcomeRule` is the only thing that would catch it.

The form's `:min` on the date input is a convenience layer, not the control — it is trivially bypassed, and it is **skipped in edit mode** so the browser does not reject an existing appointment's own pre-filled past value.

#### A cancelled appointment MUST carry `outcome = NULL`

The two columns are orthogonal, so nothing *structurally* stops `status = Cancelled` + `outcome = Attended` — a contradiction, since every outcome value asserts something about a meeting that took place and this one was called off. The rule is enforced **server-side** in `Appointments\StoreRequest::withValidator()`, inherited by `UpdateRequest` so it holds on create *and* edit and cannot be bypassed by posting directly. The form has no Outcome `<select>` to disable (removed 2026-07-31); what backs the rule up client-side is a watcher that clears the still-submitted-but-invisible `form.outcome` when *Cancelled* is picked — a guard against an edit re-submitting an already-stored outcome, not the control.

The reason for the cancellation goes in **`outcome_notes`**, which deliberately stays available on a cancelled appointment: the form shows that field whenever an outcome is set **or** the status is Cancelled — an outcome-only gate would hide it precisely where it is the only place the reason can live, and would blank the notes already saved on existing cancelled rows.

⚠️ Allowing *Follow-up Needed* as an exception here — "they cancelled, chase them to rebook" — was **considered and rejected (2026-08-03)**. The follow-up belongs to the **lead**, not to a dead appointment row: the rebooking surfaces as a **new** appointment, and the "why" already has a home in `outcome_notes`. Carving it out would split one signal across two places and force every consumer to know that one outcome value means something different from the other four. The cost, accepted knowingly: a salesperson who wants a follow-up marker on a cancelled meeting may park the row at *Confirmed* instead — corrupting the lifecycle fact to preserve the outcome fact. That is the thing to watch for in the data, and the only evidence that would re-open this. Do not loosen the validator to a permitted-value list without it.

#### `STATUSES` vs `ALL_STATUSES` — and four constants that must not be deleted

- **`STATUSES`** — the 3 assignable lifecycle values. It feeds the form dropdown *and* the `Rule::in`, so submitting a retired value now fails validation loudly instead of writing it.
- **`ALL_STATUSES`** — those 3 plus the 4 retired terminal values (Attended 3 / No Show 4 / Closed Won 5 / Closed Lost 6), for **display only**. The `status_label` / `status_color` accessors read this one, so a legacy or un-migrated row still renders a badge rather than a blank. (Same shape as `Src\Engagement\Engagement`, which solved this for its own retired statuses.)

⚠️ The retired `STATUS_ATTENDED` / `STATUS_NO_SHOW` / `STATUS_CLOSED_WON` / `STATUS_CLOSED_LOST` constants **must stay on the model** even though nothing can assign them any more. Two committed migrations reference them — the outcome backfill (`2026_07_31_000003`, all four) and [`2026_07_14_100004_backfill_engagements_from_appointments`](/database/migrations/2026_07_14_100004_backfill_engagements_from_appointments.php) (`STATUS_CLOSED_WON` / `STATUS_CLOSED_LOST`, plus the still-live `STATUS_CANCELLED`) — and per GUIDELINES §7 a committed migration is never modified, so deleting the constants fatals any fresh `migrate` / `migrate:fresh` / CI run. `@deprecated` here means "do not use", not "safe to remove". For the same reason `STATUS_CANCELLED` keeps the value **7** it has always had: the 3–6 gap is the retired block, not a gap it should be renumbered into.

### Writes — calendar Zoom meetings (`CalendarZoomMeetingsController`)
Schedules Zoom meetings from the calendar and manages the **lead-less** ones, stored in the shared `zoom_meetings` table so they reuse the Zoom API + persistence layer and surface on the grid alongside appointments. `store` may set `lead_id` from the optional lead picker (**null** = a plain calendar meeting managed here; **set** = a lead-scheduled meeting, managed from the calendar through the **Lead/Zoom** endpoints instead); the management actions here (`update`/`cancel`/`start`/`destroy`) operate only on lead-less meetings (`resolveOwnedMeeting` filters `lead_id IS NULL`). Thin controller, **Zoom-API-then-persist** order (the Zoom call happens first; the local write is delegated to the injected `ZoomMeetingRepository` only after Zoom confirms), explicit per-field mapping:
- **`store`** → `ensureAdmin()`; require the acting admin to be a **Zoom account user** (`isAccountUser`, else `flash` + `back`); resolve the **host** — the acting admin when `host_uuid` is blank (or is their own uuid), else a colleague from `assignableZoomHostQuery($actor)` who is also `isAccountUser` (either miss → `flash` + `back`, no Zoom call); `ZoomServerService::createMeeting($host->email, …)`, then persist with `admin_id` = the host's admin row (so the host owns it; `RecordsBlame`'s `created_by` still names the scheduler) (`zoom_meeting_id`, `join_url`, `password`, …) with `status = UPCOMING` and `lead_id` resolved from the optional `lead_uuid` (null when none).
- **`update`** (reschedule) → resolve owned upcoming meeting, `updateMeeting()` at Zoom, then persist topic/agenda/start/duration.
- **`cancel`** → `deleteMeeting()` at Zoom, mark `CANCELLED` locally.
- **`start`** → fetch a fresh, short-lived `start_url` from Zoom and `redirect()->away()` to it (never persisted or sent as a prop).
- **`destroy`** → soft-delete from the calendar in **any** status (best-effort `deleteMeeting()` first for a still-scheduled meeting; a 404/transient failure is logged and swallowed so local removal proceeds), so a cancelled/ended one can be cleared.
- **`invitation`** → returns the live Zoom copy-paste invitation text as **JSON** (loaded on demand by the detail modal, never stored). Owner-gated; serves **both** calendar- and lead-scheduled meetings (read-only), with the Closer gate re-applied for lead meetings.

A shared `resolveOwnedMeeting()` enforces `lead_id IS NULL` + ownership for the management actions (`invitation` deliberately omits the `lead_id` filter). Ownership is re-checked server-side on every action; `store` additionally requires `isAccountUser`.

### Timezone (`.env`-driven Asia/KL wall-clock)
`appointments.scheduled_at`, `zoom_meetings.start_time`, and `activities.start_at`/`end_at` are **all stored as Asia/Kuala_Lumpur wall-clock** (no UTC conversion). Controllers parse the admin's input in `config('app.user_timezone')` — sourced from `.env`, nothing hardcoded — and Eloquent persists it in `config('app.timezone')`; both default to `Asia/Kuala_Lumpur`. `toCalendarArray()` / `toShowArray()` serialise the stored instant with `toIso8601String()` (carrying the `+08:00` offset), so the calendar grid, the Lead page, and Zoom all agree. Funnel sessions are the one source with no single datetime column — `Event::startsAt()`/`endsAt()` assemble `scheduled_date` + the raw `start_time`/`end_time` strings, KL throughout, same as everything else. The frontend never re-converts any of it: `toKlDateKey`/`toKlTime`/`toKlStamp` (from `useKlTime()`) read the KL wall-clock portion of the ISO string directly (simple string slicing, not `Date` object math), because the offset-carrying string IS already the correct KL display value. The Vue grid still buckets/displays by KL; the detail modal shows the start as `09:00 PM, 17/06/26` (12-hour time + `DD/MM/YY`) via a dedicated `toKlStamp()` formatter, while the grid chips/drawer/Summary list keep the compact `HH:MM`. This replaced the earlier interim UTC-storage convention; the one-off shift is [`2026_06_11_000003_shift_activities_and_zoom_meetings_to_kl_time.php`](/database/migrations/2026_06_11_000003_shift_activities_and_zoom_meetings_to_kl_time.php), which is now the only surviving record of it — the root-level `timezonemigration.md` write-up this used to link is gone. ⚠️ **The `activities` create-migration (`2026_06_11_000002`, committed before the KL shift) still comments that `start_at` is UTC** — that was true for eleven days and is never edited (GUIDELINES §7: a committed migration is never modified), but do not let a stale migration comment mislead a reader; the KL shift migration above is the current source of truth.

### Colour-token inventory — every local map must carry every token

Each source's chip/badge colour is a string token (`brand`, `sky`, `indigo`, `rose`, `emerald`, `amber`, `violet`, `red`, `slate`) resolved through a **full-string Tailwind lookup** in every surface that renders one — Tailwind cannot see a dynamically-built class like `` `bg-${color}-100` ``. There is no shared map; each surface keeps its own, and a token missing from ONE renders a blank/slate badge on that surface only while looking fine everywhere else — this has already shipped as a bug twice (`indigo`/`rose` missing on Appointment's Phase 7, `red` missing on the Phase 1 sessions merge) which is why it is called out explicitly here. Current carriers, and the token each was added for:

| File | Map(s) | Token added here |
|---|---|---|
| `Calendar/Partials/EventDetailModal.vue` | `badgeColor` | `red` (Event Cancelled), `indigo`/`rose` (Appointment status/outcome) |
| `Calendar/Partials/DayDrawer.vue` | `badgeColor` + `dotColor` | `red` (Event Cancelled) |
| `Calendar/Partials/SummaryList.vue` | `badgeColor` | `red`, `indigo`, `rose` (all four sources render through this one list) |
| `Leads/Partials/Tabs/AppointmentsTab.vue` | `badge` + `dot` | `indigo`/`rose` |
| `Leads/Partials/Tabs/PipelineTab.vue` | `badge` | `indigo`/`rose` |

Adding a sixth outcome, a new session status, or any new colour on any source means checking every row in this table, not just the surface you're looking at.

### Frontend (custom month grid — deliberately *not* the §14 DataTable pattern)
A month/Summary toggle is not a paginated/sortable table, so there is no `DataTable`/`FilterDrawer`/`useResourceIndex`; a custom 7-column Tailwind grid (plus the flat `SummaryList`) is built instead (all *other* §13 rules — Inertia, shared `Drawer`/`Modal`/`ConfirmModal`, Tailwind-only, `<Link>` — still apply). Events are bucketed by KL date into one map shared by the grid and the drawer; the Summary view groups the same array by KL date into day sections instead. The detail modal is driven entirely by the server flags — never hardcoded status logic:
- `editable` appointments → **Edit** + **Delete** (`ConfirmModal` → `DELETE /manage/appointments/{uuid}`). Edit does *not* open an inline form: the modal `emit`s **`edit-appointment`** and the parent `Index.vue` opens the shared `AppointmentFormModal` in `mode="edit"`, so one form serves create, edit, the Lead tab and the calendar. An attached lead is shown with an **Open in Lead** button.
- `can_manage` (owned, upcoming meeting) → **Start** (plain `<a target="_blank">` to the `start` redirect), **Edit** (reschedule — topic / start / duration / agenda), **Cancel** (`ConfirmModal` → deletes at Zoom).
- `can_delete` (owned meeting that is **already over** — `CANCELLED` **or** `isPast()`) → **Delete** (`ConfirmModal` → removes from the calendar). The two flags are **status-exclusive** (both those states have `can_manage` false), so Delete never appears beside Cancel: you cancel a live meeting, it stays on the grid badged *Cancelled*, and only then is it clearable. `isPast()` is included because such a meeting can no longer be cancelled, so Delete is its only way off the grid. **`STATUS_ENDED` is deliberately excluded** — Zoom confirmed that meeting happened and its row is what the Recordings dashboard hangs the recording / transcript / AI analysis off, so soft-deleting it would put all of that out of reach. The wrapping `v-else-if` tests `can_manage || can_delete` (gating it on `can_delete` alone would hide Start/Edit/Cancel).
- **Lead meetings are manageable here too** (revised 2026-07-21), but only for a viewer holding **`MANAGE_ZOOM`** — their actions target `manage.zoom-meetings.*` rather than the calendar endpoints, so without that permission the buttons would only 403. The server-set **`from_lead`** flag is what the modal reads to pick the endpoint prefix; it never decides *whether* an action is offered (`can_manage`/`can_delete` do). Routing lead meetings through the Lead/Zoom endpoints is deliberate: those also **email the lead** on reschedule/cancel/delete, which the calendar endpoints do not — the `ConfirmModal` copy says so.
- `can_invite` (owned, upcoming — calendar **or** lead) → **Copy meeting invitation**, which `fetch`es the JSON `invitation` endpoint on demand and shows the text in a copy dialog (`navigator.clipboard` with a select-the-textarea fallback; `devError` on failure).
- Lead meetings (`source === 'zoom_meeting'` with `lead_uuid`) keep the read-only status badge + **Open in Lead**. ⚠️ That is a `<button>` opening the shared `Components/LeadDetailModal.vue` **in place** (`openLead(lead_uuid)`) — it does **not** navigate to `/manage/leads/{lead_uuid}`, and the appointment half's lead row works the same way. Do not go looking for a `<Link>`.
- A **session** (`source === 'session'`) is always read-only: mode badge, both status triplets, funnel/series names, description, time range, and a conditional **Open session** `<Link>` gated on `event.can_open`.
- An **activity** (`event.editable` — owner OR any assignee) → **Edit** + **Delete** (`ConfirmModal` → `DELETE /manage/calendar/activities/{uuid}`), exactly symmetric with the appointment row: Edit emits **`edit-activity`** and the parent opens the shared `AppointmentFormModal` in `mode="edit"`. The detail body shows the owner (skipped in `all` scope, to avoid duplicating the header's `owner_name` badge — an assignee viewing `mine` scope still needs to know who owns it, since unlike appointments/Zoom an activity is not self-evidently single-owner) and the assignee list.

⚠️ **The inline edit form inside `EventDetailModal.vue` is Zoom-reschedule-ONLY.** It used to carry a second, dead `v-if="!isZoomMeeting"` half (a `LeadComboBox` picker, `end_at`/`location` inputs) reachable by no button any admin could actually click — a leftover from the pre-Appointment Activity era, removed in the same pass that gave activities their new Edit button. An appointment or an activity edits via the shared `AppointmentFormModal` instead (`edit-appointment` / `edit-activity`), never inline here.

The Zoom **Join URL** opens via a plain `<a target="_blank">` (the documented external-link exception). The single create modal branches on its **Type** dropdown: an appointment type posts to `manage.appointment.appointments.store`, the `zoom` pseudo-type posts to `POST /manage/calendar/zoom-meetings` (real API scheduling, only offered when `allow-zoom` is on), and the `activity` pseudo-type posts to `manage.calendar.activities.{store,update}` (only offered when `allow-activity` is on) — **the calendar passes both**; the Lead tab and `SalesProjects/Partials/PropertyMatchView.vue` (Property Match) pass neither `allow-zoom`'s Activity sibling nor `allow-activity` at all, so the option cannot leak onto a surface that has no calendar-day/assignee context for it. `allow-activity` defaults to `false` precisely for this reason — a caller must opt in explicitly, never inherit it.

⚠️ **The CROSS-branch pseudo-type options are gated `mode === 'create'`; each branch's OWN active option is not, because it IS the bound value.** This is not "every `<select>` gates both options" — a `<select>` whose `v-model` value has no matching `<option>` renders blank, so e.g. the Zoom-mode select's own `<option value="zoom">` and the Activity-mode select's own `<option value="activity">` are necessarily always rendered whenever that branch is showing at all (the Activity select's is additionally moot in edit mode anyway, since its entire wrapper is `v-if="mode === 'create'"` — editing an existing activity has nothing sensible to switch away to, so no Type control renders there at all). What IS gated to `mode === 'create'` is every option that lets you jump **into** a DIFFERENT pseudo-type than the one you're already in — the Activity option inside the Zoom select, the Zoom option inside the Activity select, and both inside the plain Appointment select. Editing an existing appointment and switching Type to "Activity" or "Zoom meeting" mid-edit used to trap the modal this way: the chosen branch's own Type control either vanished or stayed reachable with no way back, and Save then PUT the wrong shape at an endpoint that rejects it — silently, from the admin's point of view. `AppointmentForm.vue`'s `isActivity`/`isZoom` computeds are purely presentational (`form.type === 'activity'`/`'zoom'`) and are not themselves mode-aware; the routing decision in `AppointmentFormModal.vue` is **mode-aware differently on purpose** — create checks `form.type`, but edit checks the server-authoritative `props.appointment?.source`. That split is deliberately a second, independent safety net on top of the option-gating above: even if the form's OWN branch-selection logic ever disagreed with what the modal is actually editing, submit() still can't misroute a PUT for an existing uuid, because it never trusts `form.type` to decide the endpoint once `mode === 'edit'`.

The **Activity** branch renders Title (required), Date + Start time + optional End time (composed to `start_at`/`end_at` on submit — `end_at` is composed as `null`, never `''`, when no end time is given), a Description textarea, and the assignee picker — `CallerRotationModal.vue`'s adder pattern (a single-select `ComboBox` in local-`options` mode + chosen chips with remove buttons) but **de-duped in `add()`**, since this is plain "who is on it" assignment rather than `CallerRotationModal`'s turn-weighting duplicates. The picker is **editable for everyone on the activity** — owner and any assignee alike, per the 2026-08-27 reversal above; there is no more read-only branch or `isOwner` prop gating it. Below the picker, a **"Add owner as assignee"** button (labelled **"Add me as assignee"** on the create path, where the owner and the actor are the same person) adds the activity's owner in one click via the SAME de-duping `add()` — it reads `ownerUuid` (on create, the signed-in actor's own uuid from the shared `auth.user` prop; on edit, `appointment.owner_uuid`, since anyone may now edit an activity they don't own) and is **hidden**, not merely disabled, whenever the owner is already in `form.assignees` OR is not present in the live `assignableAdmins` pool (left the group, went inactive) — offering it in that second case would submit a uuid validation rejects, the exact invisible-failure shape the `assignees.0` error-surfacing fix exists to catch. A rejected assignee (e.g. the picker's `assignableAdmins` prop going stale mid-session as someone goes inactive) surfaces its error even though the server returns it under the indexed key `assignees.0`, never a bare `assignees` key — the form scans both. Edit mode: `hydrate()` detects `appointment.source === 'activity'` and seeds every activity field from the event payload, including `assignees[].user_uuid`; it calls `form.reset()` first so a value left over from editing a DIFFERENT event type (an appointment's `title`, say — appointments have none today, but nothing stops that changing) can never bleed into the next submission.

⚠️ **The lead picker is no longer the shared `ComboBox` on the appointment/Zoom create path.** `AppointmentForm.vue` carries its **own inline debounced typeahead** against `GET /manage/leads/search` (it already had to host a project combobox, so both live in the same file); an Activity has no lead field at all (see above). The shared **`Components/ComboBox.vue`** is used instead by the Activity assignee picker (in local-`options` mode) and survives as the generic remote/local combobox for other consumers (Calls, Devices, F2F, CTA links, …) — it is no longer present anywhere in `EventDetailModal.vue` at all, now that the dead non-Zoom edit branch is gone.

### Both modals are now mounted OUTSIDE the calendar (2026-08-10)

Sales → Property Match's **Appointment** column mounts `EventDetailModal` and `AppointmentFormModal` on its own rows, so a meeting can be booked and managed without leaving the list. Since 2026-08-20 the **VSL funnel roster** does the same through the shared [`Components/AppointmentCell.vue`](/resources/js/Components/AppointmentCell.vue) + [`composables/useLeadAppointment.js`](/resources/js/composables/useLeadAppointment.js) — see [sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md#the-appointment-column-shared-with-the-vsl-roster-2026-08-20). ⚠️ **Neither of them passes `allow-activity`**, and neither should: an activity has no lead, no mirrored column and no calendar-day context to seed, so the Activity option must never appear in a lead-shaped list. `allow-activity`'s `false` default is what guarantees that without either caller having to opt out — and it is why an activity write is the one thing in this modal that deliberately does **not** emit `changed` (see below). Four things followed, and each is a contract this module now owes its callers:

- **`composables/useKlTime.js`.** `EventDetailModal` takes `toKlDateTime` / `toKlTime` / `toKlStamp` as **required props**, and those four formatters lived inside `Calendar/Index.vue`. A second consumer meant one shared definition or a hand-kept copy of four timezone conversions — and ⚠️ a copy that drifts here **does not throw**, it quietly shows an admin a meeting an hour off. `Index.vue` now destructures the composable; its local copies are gone.
- **`EventDetailModal` emits `changed`** (2026-08-20) with `{ scheduled_at }` — the new wall time, or **null** when the event is gone (cancelled or deleted). ⚠️ A list that MIRRORS this event into a column of its own cannot otherwise see a reschedule or a cancel: `saved` fires on the FORM, and reschedule / cancel / delete all happen inside the detail modal. The VSL roster mirrors into `consultation_requests.appointment_at`, which its Scheduled chips, follow-up status and export read, and which `VslLeadState::bookedLeadIds()` uses to SUPPRESS the "want a 1-1?" nurture message — so without this a cancelled appointment left that person marked as booked forever. Callers with no mirrored column ignore it.
- ⚠️ **A caller that mounts `EventDetailModal` MUST wire `@edit-appointment`.** The modal renders an **Edit** button for any editable appointment and hands the event up; unwired, the button closes the modal and does nothing. It stayed invisible while Property Match resolved Zoom meetings only, since that button never renders for a Zoom.
- **`saved` carries `{ scheduled_at }`** (2026-08-20) — the wall time just booked. The endpoints answer `back()`, so nothing about the new record reaches the browser; a caller that must mirror the time into a field of its own has no other way to learn it. The VSL roster stamps its enquiry's `appointment_at` from it, because that column (not the calendar row) is what its Scheduled filters, follow-up status and export read. Callers that only need *"something was saved"* ignore the argument.
- **`AppointmentFormModal` emits `saved`**, before it closes itself. ⚠️ "The modal closed" and "something was created" are different facts, and a caller that infers one from the other cannot tell a save from a **Cancel** — the Property Match list opens a detail modal on what was just scheduled, and popping one at somebody who backed out is worse than not popping one at all. Needed because the Zoom endpoint answers `back()`: the new meeting's uuid never reaches the browser, so the caller waits for its own reloaded props instead.
- **`AppointmentFormModal` takes `prefill-type`** — which Type a *create* opens on (`'zoom'` or an `Appointment::TYPE_*`). A **default, not a lock**: an admin who opens it from a Zoom column and realises the buyer wants a showroom visit should not have to close it and start again elsewhere. ⚠️ Passing `'zoom'` while `allow-zoom` is false would select an option that is never rendered, so the two travel together.

## Related files

**Backend — Models**
- [src/Appointment/Appointment.php](/src/Appointment/Appointment.php) — the calendar's general-purpose scheduled meeting. `TYPE_*` + `TYPES` (Showroom Visit / Video Call / Phone Call / Site Visit), `STATUS_*` + `STATUSES` (Scheduled / Confirmed / Cancelled) with `ALL_STATUSES` adding the 4 retired terminal values for display, and `OUTCOME_*` + `OUTCOMES` (Attended / No Show / Follow-up Needed / Closed / Not Closed) — see [Status vs outcome](#appointments-carry-two-facts-status-lifecycle-and-outcome-what-happened). The `outcome_label` / `outcome_color` accessors are **null-guarded** rather than using the bare `MAP[$col] ?? null` idiom the other accessors use: `??` suppresses a missing key, not a null *offset*, and `outcome` is null for most rows permanently. `lead()` / `project()` / `engagement()` / `createdByUser()`; `toCalendarArray(?int $viewerUserId)` emits `source: 'appointment'`, a derived chip `title` (`"{type} · {lead}"`, since an appointment has no free-text title), the same ISO string in **both** `start_at` (grid bucket key) and `scheduled_at` (the `datetime-local` seed the form modal re-hydrates from), both fact triplets (`status`/`status_label`/`status_color` + `outcome`/`outcome_label`/`outcome_color`) plus `outcome_notes`, `project_uuid`/`project_name` (canonical), `owner_name`, and `editable` — true only when `created_by === $viewerUserId`, with a `null` viewer meaning **not** editable.
- [src/Zoom/ZoomMeeting.php](/src/Zoom/ZoomMeeting.php) — `toCalendarArray($viewerAdminId, $canManageZoom)` produces the merged `source: zoom_meeting` entry with per-viewer flags `is_owner` / `from_lead` / `can_manage` (owned, upcoming) / `can_invite` (owned, upcoming) / `can_delete` (owned, **cancelled or past** — never concurrent with `can_manage`; ENDED excluded) — a **lead** meeting additionally requires `$canManageZoom` for the two manage flags, plus `owner_name`, `status_label`, `duration`, and `lead_uuid`/`lead_name`. See the [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) doc.
- [src/Event/Event.php](/src/Event/Event.php) — funnel sessions, owned by the [Events/Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) module; `toCalendarArray(bool $canOpenSessionPage)` is this calendar's ONLY dependency on it — see [Sessions](#sessions--the-org-wide-read-only-third-source).
- [src/Calendar/Activity.php](/src/Calendar/Activity.php) — the revived generic calendar entry. `TYPE_ACTIVITY` / `TYPE_ZOOM` + `TYPES` (the latter a legacy pasted-Zoom-link kind with no write path of its own — see [Activity: retired, then revived](#activity-retired-2026-06-then-revived-2026-08-27-with-multi-admin-assignment)); `admin()` (owner, `BelongsTo Admin`), `lead()` (optional reference, informational only — attaching one does NOT change editability, unlike `ZoomMeeting`), `assignees(): BelongsToMany` (→ `Admin` via `activity_admins`, `admins.id` space both sides — see [Assignment model](#assignment-model--the-activity_admins-pivot)); `toCalendarArray(?int $viewerAdminId)` emits `source: 'activity'`, `is_owner`, `editable` (owner OR assignee; `null` viewer ⇒ **false**), `owner_name`, `owner_uuid` (the owner's `users.uuid` — feeds the edit form's "Add owner as assignee" button; same shape as `assignees[].user_uuid`), and `assignees: [{user_uuid, name}]`.

**Backend — Repositories**
- [src/Appointment/Repositories/AppointmentRepository.php](/src/Appointment/Repositories/AppointmentRepository.php) — `create` / `update` / `delete` for the appointments the grid renders (transactional; nested `$input['appointment']`). Owned by the Appointment module; the calendar never calls it directly.
- [src/Zoom/Repositories/ZoomMeetingRepository.php](/src/Zoom/Repositories/ZoomMeetingRepository.php) — `create` / `update` / `cancel` / `delete` for `zoom_meetings` (transactional; nested `$input['zoom_meeting']`); shared with the Lead/Zoom flow. Never calls the Zoom API.
- [src/Calendar/Repositories/ActivityRepository.php](/src/Calendar/Repositories/ActivityRepository.php) — `create` / `update` / `delete` for `activities` (transactional; nested `$input['activity']`), plus the `assignees()->sync()` call for the pivot, sorted + de-duplicated before syncing so an identical set in a different order is a no-op (no pivot-row churn). `update()` is the one place the absent-vs-present-empty `assignee_admin_ids` distinction lives — see the [⚠️ note above](#writes--calendar-activities-managecalendaractivitiescontroller).
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — `ensureAdmin(User): Admin`, the owner-row safety net (lock + `withTrashed()` restore-or-create); called for the actor AND for each newly-assigned Activity admin.

**Backend — Services**
- [src/Zoom/Services/ZoomServerService.php](/src/Zoom/Services/ZoomServerService.php) — the account-level Server-to-Server Zoom-API caller: `createMeeting($host, …)` / `updateMeeting` / `deleteMeeting` / `getMeeting` (fresh `start_url`) / `getMeetingInvitation` / `isAccountUser`. See the [Zoom](/docs/modules_handbook/manage/zoom/readMe.md) doc.

**Backend — Controllers** (`app/Http/Controllers/Manage/Calendar/`)
- [CalendarController.php](/app/Http/Controllers/Manage/Calendar/CalendarController.php) — the **only** controller in this folder that serves the page, and it never writes. `index`: read-only merged payload (month **or** Summary window, same method — see [Reads](#reads--the-merged-payload-calendarcontrollerindex)); eager-loads across all four sources; adds `appointmentTypes` / `appointmentStatuses` / `appointmentOutcomes` / `projects` / `scope` / `canViewAll` / `zoom: { connected }` / `assignableAdmins` / `view` / `dateFrom` / `dateTo`.
- Appointment writes: [app/Http/Controllers/Manage/Appointment/AppointmentsController.php](/app/Http/Controllers/Manage/Appointment/AppointmentsController.php) — `store` / `update` / `destroy` / **`outcome`**, owner-only on `created_by`. `outcome` is the one-click endpoint and re-checks the Cancelled rule against the stored row (see above).
- [CalendarZoomMeetingsController.php](/app/Http/Controllers/Manage/Calendar/CalendarZoomMeetingsController.php) — `hosts` / `store` / `update` / `cancel` / `start` / `destroy` / `invitation` (injects `UserRepository` + `ZoomMeetingRepository` + `ZoomServerService`; Zoom-API-then-persist; `isAccountUser` + owner gated). `store` resolves the optional `lead_uuid` → `lead_id` (open to any Zoom account user); the management actions stay scoped to lead-less meetings.
- [ActivitiesController.php](/app/Http/Controllers/Manage/Calendar/ActivitiesController.php) — `store` / `update` / `destroy` for `activities` — see [Writes — calendar Activities](#writes--calendar-activities-managecalendaractivitiescontroller).

**Backend — Concerns** (`app/Http/Controllers/Concerns/`)
- [ResolvesAssignableManagers.php](/app/Http/Controllers/Concerns/ResolvesAssignableManagers.php) — `assignableStaffQuery()` (active + manage-role + group-scoped base), `assignableManagerQuery()` (that plus `SALES_EXECUTION`, for lead assignment) and `assignableAdminOptions()` (that base with NO sales filter, for the Activity picker) — one bounded-pool definition shared by every "who can be assigned" surface, so the validated set and the offered set can never disagree.
- [NotifiesActivityAssignees.php](/app/Http/Controllers/Concerns/NotifiesActivityAssignees.php) — the `calendar.activity_assigned` publisher; see [Notification](#notification--calendaractivity_assigned).

**Backend — Form Requests** (`app/Http/Requests/Manage/Calendar/`)
- [CalendarQueryRequest.php](/app/Http/Requests/Manage/Calendar/CalendarQueryRequest.php) — index query (extends `QueryRequest` directly, no filterables — not a table). Its `$model` is `Src\Calendar\Activity` — no longer inert, now that the revival means the calendar genuinely reads and writes `activities` rows.
- Appointment validation lives with its module: `app/Http/Requests/Manage/Appointment/Appointments/`. Its `StoreRequest` is also where the **Cancelled × outcome** rule is enforced, in `withValidator()` — inherited by `UpdateRequest`, so both write paths are covered (see [that section](#a-cancelled-appointment-must-carry-outcome--null)).
- [Activities/StoreRequest.php](/app/Http/Requests/Manage/Calendar/Activities/StoreRequest.php) — `title` required, `start_at` required `after:now` (KL like-for-like, mirrors Appointments), `end_at` nullable `after:start_at`, `details` nullable, `assignees.*` `distinct` + `Rule::in($assignable)` against the bounded pool — **never `exists:users,uuid`**, see [the ⚠️ above](#writes--calendar-activities-managecalendaractivitiescontroller). `distinct` is behavioural, not cosmetic: a literal duplicate uuid in the payload FAILS validation (422) rather than being silently collapsed — the repository's own de-duplication only ever sees a set that already passed this rule.
- [Activities/UpdateRequest.php](/app/Http/Requests/Manage/Calendar/Activities/UpdateRequest.php) — extends Store; drops `after:now` (re-applied only when the time actually moves, same mechanism as `Manage\Appointment\Appointments\UpdateRequest`); overrides `assignableUuids()` to union in the activity's current assignees (N5) so `assignees` validates identically for the owner or any assignee.
- [ZoomMeetings/StoreRequest.php](/app/Http/Requests/Manage/Calendar/ZoomMeetings/StoreRequest.php) — `topic`/`agenda`/`start_time`/`duration`/`lead_uuid`(nullable, `exists:leads,uuid`) for scheduling a calendar Zoom meeting.
- [ZoomMeetings/UpdateRequest.php](/app/Http/Requests/Manage/Calendar/ZoomMeetings/UpdateRequest.php) — extends Store for the reschedule; ownership enforced in the controller.

**Backend — relations**
- [src/People/Admin.php](/src/People/Admin.php) — `zoomMeetings(): HasMany` and `activities(): HasMany` (the OWNED activities, `activities.admin_id`) are both live. Appointments hang off the **user**, not the admin (`created_by`), so there is no `Admin::appointments()`.

**Frontend (Vue)** (`resources/js/Pages/Manage/Calendar/`)
- [Index.vue](/resources/js/Pages/Manage/Calendar/Index.vue) — 7-column month grid + the Month/Summary toggle; KL display/bucketing; `toKlTime` (chips, `HH:MM`) + `toKlStamp` (modal, `09:00 PM, 17/06/26`) formatters; prev/next month; opens the drawer + detail modal; passes `zoom.connected` + `allow-activity` + `assignable-admins` + `scope` down. **Scope toggle:** a **My events / All admins** segmented control rendered only when `canViewAll` (Super Admin or Sales Leader), which reloads with `?scope=` (preserved across prev/next/today nav and the Month↔Summary toggle); in `all` scope each event chip shows the owner's first name in a deterministic per-owner accent colour (`ownerAccentClass(owner_name)`).
- [Partials/DayDrawer.vue](/resources/js/Pages/Manage/Calendar/Partials/DayDrawer.vue) — a day's events (uses `Components/Drawer.vue`); header **Create** button which opens the shared appointment modal directly (prefilled to the clicked day at 09:00); an activity row shows its assignee count.
- [Partials/SummaryRangeBar.vue](/resources/js/Pages/Manage/Calendar/Partials/SummaryRangeBar.vue) — the Summary view's range picker; see [The Summary view](#the-summary-view).
- [Partials/SummaryList.vue](/resources/js/Pages/Manage/Calendar/Partials/SummaryList.vue) — the Summary view's flat, day-grouped event list; see [The Summary view](#the-summary-view).
- **Create — appointment + Zoom + Activity in one modal.** The day drawer's Create opens the shared [Appointment/Partials/AppointmentFormModal.vue](/resources/js/Pages/Manage/Appointment/Partials/AppointmentFormModal.vue) with `allow-zoom="zoom.connected"` and `allow-activity="true"`. Its **Type** dropdown then offers **"Zoom meeting"** and **"Activity"** pseudo-types alongside the four appointment types (create-only — see the ⚠️ in the Frontend section above); picking one morphs [AppointmentForm.vue](/resources/js/Pages/Manage/Appointment/Partials/AppointmentForm.vue) into that layout and routes submit to `zoom-meetings.store` or `activities.{store,update}` respectively, instead of writing an appointment row. Appointment types keep posting to `appointment.appointments.store`. The Lead Show tab reuses the same modal with **neither** flag, so it never shows either pseudo-type (leads keep their dedicated `Leads/Partials/ZoomMeetingModal.vue`, and have no use for a team-coordination Activity at all). The appointment shape carries **no Outcome select** (removed 2026-07-31 — the modal deliberately passes no `:outcomes`; outcomes are recorded by the one-click buttons instead), though `form.outcome` is still hydrated and still submitted, and a watcher clears it when *Cancelled* is picked. **Outcome Notes** shows whenever an outcome is set **or** the status is Cancelled.
- [Partials/EventDetailModal.vue](/resources/js/Pages/Manage/Calendar/Partials/EventDetailModal.vue) — event detail driven by server flags, handling all FOUR sources: an **appointment** (`source === 'appointment'`) renders read-only detail (type / status / outcome / project / location / notes / outcome notes / lead — the outcome badge is simply absent when none is recorded, never an "unknown" chip) with Edit + Delete for the owner only — Edit emits **`edit-appointment`** so the *parent* opens the shared `AppointmentFormModal`, Delete goes to `DELETE /manage/appointments/{uuid}` via `ConfirmModal`, and another admin's appointment in `all` scope is `editable=false` (view-only); a **Zoom meeting** gets Start/Edit(reschedule)/Cancel/Delete off `can_manage` / `can_delete`, with the endpoint prefix picked by the server-set `from_lead` flag (never *whether* an action is offered), plus the ONLY inline edit form this modal carries; a **session** (`source === 'session'`) is always read-only; an **activity** (`source === 'activity'`) gets Edit (emits **`edit-activity`**) + Delete (`DELETE /manage/calendar/activities/{uuid}`) for the owner or any assignee, per `event.editable`. **Copy meeting invitation** (on-demand `fetch` → copy dialog) for any owned upcoming meeting; read-only status badge + "Open in Lead" for lead meetings. Its local `badgeColor()` map is one row of the [colour-token inventory](#colour-token-inventory--every-local-map-must-carry-every-token) above.
- [Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar "Calendar" nav item (`CalendarDays` icon).

**Frontend — shared component**
- [resources/js/Components/ComboBox.vue](/resources/js/Components/ComboBox.vue) — generic, domain-agnostic remote-search typeahead (the calendar was its first consumer; it is now used across Calls, Devices, F2F, CTA links, the Activity assignee picker, …). Configurable `searchUrl` / `options` (local mode, what the Activity picker uses) / `valueKey` / `labelKey` / `sublabelKey` / `responseKey` / `queryParam`; debounced `fetch` (remote mode), keyboard nav, outside-click close, `v-model` + `select`/`clear` emits, `#option` slot. It no longer appears anywhere in `EventDetailModal.vue` — the dead non-Zoom edit branch that used to host it there is gone (see the Frontend section above); the live lead picker on the create/edit form is `AppointmentForm.vue`'s own typeahead.

**Migrations** (`database/migrations/`)
- [2026_06_11_000001_add_unique_to_admins_user_id.php](/database/migrations/2026_06_11_000001_add_unique_to_admins_user_id.php) — UNIQUE index on `admins.user_id` (DB backstop for `ensureAdmin`).
- [2026_06_11_000002_create_activities_table.php](/database/migrations/2026_06_11_000002_create_activities_table.php) + [2026_06_18_000001_add_lead_id_to_activities_table.php](/database/migrations/2026_06_18_000001_add_lead_id_to_activities_table.php) — the `activities` table itself, unchanged by the revival (it already had every column the revived feature needs). ⚠️ The create-migration's own comment says `start_at` is UTC — see the Timezone section's note on why that stale text stays put and stays wrong.
- [2026_06_11_000003_shift_activities_and_zoom_meetings_to_kl_time.php](/database/migrations/2026_06_11_000003_shift_activities_and_zoom_meetings_to_kl_time.php) — the one-off UTC → Asia/KL wall-clock shift (see the Timezone note).
- [2026_06_16_100001_create_appointments_table.php](/database/migrations/2026_06_16_100001_create_appointments_table.php) + [2026_06_16_100002_add_outcome_notes_to_appointments_table.php](/database/migrations/2026_06_16_100002_add_outcome_notes_to_appointments_table.php) + [2026_07_14_100003_add_engagement_id_to_appointments_table.php](/database/migrations/2026_07_14_100003_add_engagement_id_to_appointments_table.php) — the `appointments` table the grid actually reads.
- [2026_07_31_000002_add_outcome_to_appointments_table.php](/database/migrations/2026_07_31_000002_add_outcome_to_appointments_table.php) — the nullable, indexed `outcome` column. **No default** — `NULL` is the meaningful "not recorded" state.
- [2026_07_31_000003_backfill_appointment_outcomes.php](/database/migrations/2026_07_31_000003_backfill_appointment_outcomes.php) — the data move: every row sitting on a retired terminal status (3–6) gets the matching `outcome` and is reset to `CONFIRMED` (a terminal appointment was by definition a confirmed one). Keyed on the *legacy* status, so re-running is a no-op and can never clobber an outcome recorded later; soft-deleted rows are migrated too, deliberately. ⚠️ **Effectively one-way** — `down()` restores the legacy statuses from `outcome`, but *Follow-up Needed* has no legacy equivalent and collapses to a bare `Confirmed`. Only `migrate:rollback --step=1` is lossless (it leaves `outcome` populated on purpose); a full rollback drops the column and takes the follow-up fact with it.
- [2026_08_27_100001_create_activity_admins_table.php](/database/migrations/2026_08_27_100001_create_activity_admins_table.php) — the NEW pivot: `activity_id` + `admin_id` (**`admins.id` space — the SAME space `activities.admin_id` uses, NOT `engagement_assignments.admin_id`'s `users.id`**), unique on the pair, no schema FKs (§7), no pivot model.
- [2026_08_27_100002_backfill_activity_assigned_subscriptions.php](/database/migrations/2026_08_27_100002_backfill_activity_assigned_subscriptions.php) — subscribes every existing personal Notify destination to `calendar.activity_assigned`; see [Notification](#notification--calendaractivity_assigned) for why this can't just be `default: true` on its own.

**Routes**
- [routes/web.php](/routes/web.php) — the `manage.calendar.*` group (`Route::prefix('calendar')`) inside the `manage` `['auth','admin']` group, carrying `permission:view-calendar`. It holds `index` GET, the nested `calendar.zoom-meetings.*` (`store` / `update` / `destroy` / `cancel` — each additionally `permission:manage-calendar` — and the read-only `start` / `invitation`, plus the JSON `hosts` list behind the Zoom **Host** dropdown — `permission:manage-calendar` like `store`, declared before the `{id}` routes; being a Zoom account user is enforced in the controller by `isAccountUser`, not by middleware), and the nested **`calendar.activities.*`** (`store` / `update` / `destroy`, all `permission:manage-calendar` — owner-or-assignee ownership is enforced in `ActivitiesController`, not by middleware, since the permission alone can't tell who owns which row). The appointment writes the page uses live in a **separate** `Route::prefix('appointments')` group named `manage.appointment.appointments.*`.

**Tests** (`tests/Feature/Calendar/`)
- [CalendarAccessTest.php](/tests/Feature/Calendar/CalendarAccessTest.php) — audience gate + owner-only merge (no other admin's events leak).
- [CalendarScopeTest.php](/tests/Feature/Calendar/CalendarScopeTest.php) — `?scope=all` for both **Super Admin and Sales Leader**, on **both** the month grid and the Summary view (sees every creator's appointments + zoom meetings + activities with `owner_name` and `editable=false` on others'); a normal Admin **cannot** escalate to `all`; the default scope is owner-only for everyone. It builds `Appointment` rows keyed on `created_by`, which is the clearest statement of the multi-owner-space rule.
- [CalendarSessionsTest.php](/tests/Feature/Calendar/CalendarSessionsTest.php) — an admin holding ONLY `view-calendar` sees `source: 'session'` rows with no extra permission; cancelled sessions included; visible in both scopes; `editable === false` always; `can_open` true only with `view-events`/`view-events-reports`; the window boundary on `scheduled_date` (date-string compare); the shape carries both status triplets + funnel name.
- [ActivityCrudTest.php](/tests/Feature/Calendar/ActivityCrudTest.php) — the revived write path end to end: `store` writes the activity + pivot rows in the `admins.id` space, provisioning an assignee's admin row on the write path; the owner AND any assignee can update/delete AND change the roster (add-and-remove in the same write, both sticking); an unrelated admin holding `manage-calendar` 403s on both; `admin_id` is immutable on update; the assignee sync is order-independent + de-duplicated; a customer/lead uuid and an out-of-group admin are both rejected as assignees regardless of who submits them (`assertSessionHasErrors('assignees.0')`, and the negative — nothing provisioned, no `is_staff` flip, the whole write rejected not just the roster); the absent-vs-present-empty `assignees` contract (both directions); the N5 pool-divergence fix (an edit resubmitting a now-unassignable roster still succeeds for whoever is editing); `mine` scope returns owned-or-assigned only; the index GET never provisions an admin row for a seeded Super Admin; the `toCalendarArray` editable matrix (owner / assignee / stranger / null-viewer ⇒ false).
- [CalendarSummaryTest.php](/tests/Feature/Calendar/CalendarSummaryTest.php) — `?view=summary` defaults to the last-7-days window; `date_from`/`date_to` honoured; malformed dates fall back to the whole default range; an empty single side keeps the other; `from > to` is swapped; the 92-day cap; all four sources present in the window; appointment rows carry the outcome triplet + `lead_uuid`.
- [ActivityAssignmentNotifyTest.php](/tests/Feature/Calendar/ActivityAssignmentNotifyTest.php) — `Queue::fake()` + a forced `notify.telegram.bot_token` (the local/CI env has none, which otherwise makes the transport silently unconfigured and every assertion pass for the wrong reason — see the class docblock): create with an actor + one other assignee queues exactly one job, never addressed to the actor; a plain edit with the roster unchanged queues zero; adding one assignee queues exactly one, addressed to the new assignee's `users.id` only, never the already-assigned one, whether the editor is the owner OR a non-owner assignee (anyone may change the roster since 2026-08-27). Deliberately burns a couple of throwaway `users.id` values before its first real fixture so `admins.id` and `users.id` diverge — otherwise the `admins.id → users.id` hop this whole feature rests on could be skipped by a regression and every assertion would still pass by coincidence.
- [MeetingStatusTest.php](/tests/Feature/Calendar/MeetingStatusTest.php) — the derived **"Past"** display status (a still-scheduled meeting whose window elapsed without a Zoom-confirmed end; never stored, `status` stays Upcoming) and the suppression of Start / Copy-invitation / edit for it; `ENDED` and `CANCELLED` are terminal and never reclassified.
- [CalendarProjectCanonicalTest.php](/tests/Feature/Calendar/CalendarProjectCanonicalTest.php) — the project selector and appointment chips render **catalogue** facts, never the stale `projects.*` cache, and a month of appointments eager-loads the catalogue (no N+1).
- [EnsureAdminTest.php](/tests/Feature/Calendar/EnsureAdminTest.php) — `UserRepository::ensureAdmin` unit-level: creates the row for a user with none, returns the existing one, is idempotent, and **restores** a soft-deleted row rather than colliding with it.
- [LeadAssociationTest.php](/tests/Feature/Calendar/LeadAssociationTest.php) — the optional lead on **calendar Zoom meetings**: server-side `lead_uuid`→`lead_id`, `exists` validation, the `isAccountUser` gate (a non-Zoom-account-user is refused), lead-less paths unaffected. Appointments can carry a lead too, but that half is covered by the Appointment suite.

Appointment CRUD is tested with the module that owns it, not here: [tests/Feature/Appointment/AppointmentOwnershipTest.php](/tests/Feature/Appointment/AppointmentOwnershipTest.php) (owner-only **delete** *and* **update** — a super admin only *views* others' events, so it 403s on both; note a 403 test must post an otherwise-valid payload, since the Form Request resolves **before** the controller's `abort_if`) and [tests/Feature/Appointment/AppointmentCalendarArrayTest.php](/tests/Feature/Appointment/AppointmentCalendarArrayTest.php) (`Appointment::toCalendarArray`, the normalized shape the grid/drawer/detail/Summary consume).

The outcome column has its own two suites: [tests/Feature/Appointment/AppointmentOutcomeTest.php](/tests/Feature/Appointment/AppointmentOutcomeTest.php) (store/update mapping including "absent outcome stays `NULL`, never `0`", the Cancelled × outcome rule in both payload shapes, the narrowed `Rule::in` rejecting a retired status, the calendar triplet, and an `ALL_STATUSES ⊇ STATUSES` drift guard) and [tests/Feature/Database/AppointmentOutcomeBackfillMigrationTest.php](/tests/Feature/Database/AppointmentOutcomeBackfillMigrationTest.php), which drives the backfill migration directly and pins **both** its forward and reverse tables — including the two documented losses and the `= CONFIRMED` guard in `down()` (a `!= CANCELLED` guard would rewrite a valid `Scheduled` + `Attended` row's status; only that one test catches it).

The derived phase and the two behaviours built on it add three more suites: [tests/Feature/Appointment/AppointmentPhaseTest.php](/tests/Feature/Appointment/AppointmentPhaseTest.php) (the resolution order — cancelled outranks done, done outranks timing — plus the `ONGOING_WINDOW_MINUTES` boundary pinned on **both** sides and derived from the constant, so retuning the window keeps the test honest), [tests/Feature/Appointment/AppointmentQuickOutcomeTest.php](/tests/Feature/Appointment/AppointmentQuickOutcomeTest.php) (the endpoint: toggle-to-clear, ownership incl. no super-admin override, and the re-applied Cancelled guard *with* its deliberate clearing exemption) and [tests/Feature/Appointment/AppointmentSchedulingWindowTest.php](/tests/Feature/Appointment/AppointmentSchedulingWindowTest.php) — whose `testCanEditAPastAppointmentWhenTheTimeIsUnchanged` is the guard on the regression that `after:now` would otherwise cause, freezing every historical appointment against editing.

**Frontend tests** — [resources/js/Pages/Manage/Appointment/Partials/appointment-form.test.js](/resources/js/Pages/Manage/Appointment/Partials/appointment-form.test.js) pins the shared form's two pseudo-types: the CROSS-branch options are create-mode-only (see the ⚠️ above), a rejected assignee's `assignees.0`-keyed error still renders, `hydrate()` never bleeds one event type's fields into the next, `end_at` composes as `null` not `''`, and the "Add owner as assignee" button adds the owner and disappears once they're already on the roster. This is the branch-routing logic a QA mutation test proved would otherwise ship silently broken.

> ⚠️ This doc used to defer to a root-level `timetableintegration.md` for the full design rationale and phase plan. That file no longer exists and nothing replaced it — this handbook is now the only written record, so keep the *why* here rather than pointing elsewhere. The full implementation history and the decisions ratified along the way live in `plan_doc/calendar-sessions-activities-summary.md`.
