# CR-10 — Pending Tasks (dashboard widget + follow-up history completion)

Requirements by the BA (Fable), 2026-09-02, from the client's instructions of
2026-09-02 and the AskUserQuestion decisions the same day (§8). Implementation
follows `docs/adding-a-module.md` where this document does not say otherwise.
This CR extends the **inquiries module (CR-6)** and the **dashboard** — both are
house-convention surfaces (lang files, policies, tests). None of CR-8/9's §0
client overrides apply here.

Read first: `docs/inquiry-module-requirements.md` (the module being extended),
`app/Enums/FollowUpType.php`, `app/Services/Inquiries/FollowUpRecorder.php`,
`resources/views/inquiries/partials/history.blade.php`,
`resources/views/dashboard.blade.php`, `app/DataTables/` NOT needed (no
DataTable in this CR — §6.4).

---

## 1. What the client asked for (verbatim intent)

1. A follow-up of type **Digital Brochure, Physical Brochure, Auto Cad Plan,
   PDF Plan, Structure Plan, Offer Request** recorded **without a date** is a
   *pending task*. Show these in a **Pending Tasks** section on the dashboard,
   **before Recent Bookings**: inquiry number, customer name, task name, each
   task as its own row, with an **Action** column holding a ✓ (mark-complete)
   button. Clicking it asks *"Are you sure you want to mark this task as
   complete?"*; on Yes the task is completed **in the follow-ups table**,
   today's date is saved in its `date` field, and it leaves the pending list.
2. The same pending state shows inside **Follow Up History** (a "Pending"
   status on the entry) with the same ✓ button; once completed, the history
   shows **the date and time it was completed and who completed it**.
3. A follow-up of type **Personal Meeting, Site Visit, Follow Up Call** also
   shows in Pending Tasks, **with its scheduled date**. Its ✓ button opens a
   popup asking for a **conclusion** (textarea); saving the conclusion marks
   the task complete and stores the conclusion on that record.

## 2. Schema

### 2.1 `follow_ups` — three new columns (one migration)

| column         | type                                            | nullable |
|----------------|--------------------------------------------------|----------|
| `conclusion`   | `text`                                           | yes      |
| `completed_at` | `timestamp`                                      | yes      |
| `completed_by` | `foreignId` → `users`, `nullOnDelete`            | yes      |

All three are **server-owned**: absent from `FollowUp::$fillable`, absent from
every rule set, written only by `forceFill` in the writers of §3. Add them to
the model's `@property` block and cast `completed_at => 'datetime'`.
`conclusion` follows the CR-9 NOTE_MAX lesson: validation max is **5000
characters** (TEXT holds 65535 *bytes*; multibyte input at the byte limit is a
SQLSTATE 22001 → 500).

### 2.2 Data migration — inquiries already booked

The rule of D3 (booking confirm auto-completes tasks) must also hold for
inquiries that reached `booking_confirm` **before** this CR. A second migration
(or the same one, after the columns) bulk-updates every *pending* (per §3.1)
non-soft-deleted `follow_ups` row whose inquiry has `status = 'booking_confirm'`
and is not soft-deleted:
`conclusion = 'Booking Confirm'`, `completed_at = now()`, `completed_by = NULL`
(system action — no human did it), and for the six document types
`date = today` where `date` is NULL. Plain query-builder writes, no models, no
activity rows (migrations have no actor).

## 3. Behavior

### 3.1 The pending test (one definition, used everywhere)

A `follow_ups` row is **pending** iff it is not soft-deleted AND
`completed_at IS NULL` AND:

- type ∈ {1 DigitalBrochure, 2 PhysicalBrochure, 5 AutoCadPlan, 6 PdfPlan,
  7 StructurePlan, 8 OfferRequest} (**document tasks**): `date IS NULL`.
  A document row saved *with* a date was never pending and never gains
  completion metadata — it renders in history exactly as today.
- type ∈ {3 PersonalMeeting, 4 SiteVisit, 9 FollowUpCall} (**activity
  tasks**): always (they carry a required date+time; pending until a
  conclusion is recorded). Existing pre-CR-10 activity rows therefore all
  start pending — intended.
- type 10 (NotInterested): **never** pending.

Add two enum methods to `FollowUpType`, `default`-less matches like the
existing four: `isDocumentTask(): bool` and `isActivityTask(): bool`
(NotInterested answers false to both). Express the pending test once — an
Eloquent scope `FollowUp::scopePending()` — and reuse it in the dashboard
query, the history partial's per-row check (via a model helper
`isPending(): bool` that mirrors the scope; keep the two adjacent with a
comment binding them), the complete endpoint, and the auto-complete.

### 3.2 Who sees a task / who may complete it (D4 — row-scoped, not permission-scoped)

One rule for dashboard visibility AND the complete action, expressed as a new
`FollowUpPolicy::complete(User $user, FollowUp $followUp): bool`:

- a user holding the new permission **`follow_ups.manage`** (§7.2 — Admin
  only; Super Admin passes via the existing `Gate::before`) → every task;
- otherwise: `inquiry.created_by === user->id` OR
  `inquiry.assigned_to === user->id` OR `follow_ups.assigned_to === user->id`
  (the site-visit assignee the client named explicitly).

The dashboard query applies the same rule in SQL (manage → unfiltered; else a
`where` over those three columns). The client's words: "Inquiry created,
Assigned To and admin can [see] this all task. and take action on that. If
meeting assign to other person than that task show to that user as well."
Note this deliberately does NOT require `inquiries.view`/`inquiries.edit`: an
assignee outside the Sales role must still see and complete their own task
from the dashboard.

### 3.3 Mark complete — the single writer

Follow-up rows were **append-only by CR-6 decision** (`FollowUp` docblock).
CR-10 supersedes that with exactly one sanctioned mutation, and it lives in the
module's single writer, `FollowUpRecorder`:

`complete(FollowUp $followUp, ?string $conclusion): void`
- One `DB::transaction`. Re-read the row `lockForUpdate()`; if it no longer
  passes §3.1 → `ValidationException::withMessages(['task' => 'This task has
  already been completed.'])` (the two-tab race; friendly, not a 500). Only
  the `follow_ups` row is locked — no inquiries/bookings/units locks, so the
  module lock-order invariant (inquiries → bookings → units) is untouched;
  state that in the method docblock.
- Document task: `forceFill(['date' => today])` (the client's "save today's
  date in date field"); any posted conclusion is dropped, not stored.
- Activity task: `forceFill(['conclusion' => $conclusion])` (required — §5).
- Both: `completed_at = $followUp->freshTimestamp()`,
  `completed_by = Auth::id()`, `updated_by = Auth::id()`.
- **Audit (the CR-9 D15 lesson):** `completed_*`/`conclusion` are non-fillable,
  so `HasAudit`'s `logFillable()` cannot see them. Write ONE explicit row:
  `activity()->performedOn($followUp)->causedBy(Auth::user())
  ->event('task_completed')->withProperties([...])->log('Task completed')` —
  `->event()` set explicitly (manual `log()` leaves spatie's `event` column
  NULL). Because the document path also dirties the *fillable* `date`,
  suppress the automatic spatie row around the save
  (`$followUp->disableLogging()` / re-enable) so each completion produces
  exactly one audit row, not two.

### 3.4 Booking confirm auto-completes (D3)

In `BookingService`, at the point that writes
`inquiries.status = booking_confirm` (the confirm write inside
`applyInquiryEffects`' path, currently ~line 868-873): in the same transaction,
bulk-complete every pending (§3.1) follow-up of that inquiry —
`conclusion = 'Booking Confirm'`, `completed_at = now`,
`completed_by = Auth::id()`, and `date = today` for document rows with NULL
date. A bulk query-builder UPDATE is acceptable here (D8): no per-row activity
rows — the booking's own audit trail records the confirmation, and the
`completed_*` columns tell the per-row story. Cancelling a booking later
(CR-9's `revertInquiry`) does **not** un-complete anything — completions are
never reversed.

### 3.5 Not Interested drop-off (D2)

The dashboard query excludes tasks whose inquiry has
`status = 'not_interested'`. Nothing is written — if the inquiry re-enters the
funnel (an interested follow-up moves it back to `follow_up` per
`FollowUpRecorder::applyLifecycle`), its still-pending tasks reappear. Inside
the inquiry's own Follow Up History the Pending badge and ✓ button remain
visible regardless of inquiry status (drop-off is dashboard-only).

## 4. Dashboard ordering & pagination (D1, D5)

Order, per the client: **"If today's date then show on top else show on
ascending order at last."** As one SQL ordering over the pending set:

1. rows with `date = CURDATE()` first (only activity tasks can match —
   pending document tasks are dateless by definition), oldest `id` first;
2. then all other dated rows ascending by `date` (overdue before future);
3. then the dateless document tasks, ascending by `created_at`, `id`.

e.g. `ORDER BY (date = ?) DESC, date IS NULL ASC, date ASC, id ASC` with
today bound — constant SQL, no user input in the ORDER BY.

**Pagination:** a standard Laravel paginator, 10 per page, page name
`tasks_page`, `->withQueryString()`, links fragment-anchored to the card so a
page click lands back on it. NOT a yajra DataTable (D6): no search, no
user-chosen sort, no AJAX — a paginator keeps the dashboard's one dynamic card
dependency-free. Out-of-range pages render the empty last-page state the
paginator gives; no crash.

`DashboardController` stops passing nothing: it builds the scoped, ordered,
paginated query (eager-loading the inquiry with only the columns the card
needs) and passes it to the view. Every other dashboard widget stays
placeholder — do not touch them.

## 5. Validation, authorization & tamper cases

New `App\Http\Requests\Inquiries\CompleteFollowUpRequest`:

- `authorize()` → `$this->user()->can('complete', $followUp)` (§3.2 policy).
  Out-of-scope user → 403. Route model binding gives soft-deleted → 404.
- `rules()`: `conclusion => ['nullable', 'string', 'max:5000']`.
- `after()`:
  - type 10 → reject (`This entry cannot be completed.`) — also covers any
    future type left out of both `isDocumentTask`/`isActivityTask`;
  - activity task and `conclusion` blank → `The conclusion is required.`;
  - not pending (pre-lock friendly check; the locked re-read in
    `complete()` is the authoritative one) → `already been completed`.
- Exclude the payload from jsvalidation client rules if the shared modal's
  form would otherwise pick up stray rules (follow the
  `clientRuleExclusions()` pattern of `StoreFollowUpRequest`).

Tamper surface to close (and prove in tests §9):
- POSTing a foreign `{followUp}` id (another user's scope) → 403, no write.
- POSTing conclusion for a document task → stored NULL (dropped), date still
  set to today.
- Double-submit / two tabs → second gets the friendly message, row written
  once, one audit row.
- `tasks_page` and any query noise → paginator input only; never int-cast
  without the `is_scalar` + `ctype_digit` guard if read manually (prefer the
  paginator's own handling).
- All rendered values (`conclusion`, customer name, task label, inquiry code)
  escaped via `{{ }}`; modal strings via `data-strings` JSON / `Js::from()`;
  **no innerHTML** anywhere in the new JS.

## 6. UI — pixel mapping

No wireframe page exists for Pending Tasks (`docs/wireframes-v12/index.html`
has no such card — this is a client addition). Fidelity therefore means:
**mirror the existing dashboard card anatomy exactly** (the Recent Bookings
card: `.card` > `.card-header` with `h5.card-title` + icon + right-slot,
`.table-wrapper` > `table.data-table`) and the inquiries module's own `fu-*`
visual language for anything follow-up-flavored. New CSS goes in the
app-additions tail of `public/assets/css/nrems-wireframe.css` as the next
numbered block, commented like its neighbours.

### 6.1 Dashboard card

Full-width `.card mb-4` inserted in `resources/views/dashboard.blade.php`
**between the charts row and the Recent Bookings `.d-flex` row** ("before
Recent Bookings"). Header: `fa-solid fa-list-check` icon + "Pending Tasks" +
a `badge badge-warning` carrying the scoped pending TOTAL (not the page
count). **Rendered ALWAYS** (client decision 2026-09-08, reversing D7): at zero
the badge reads 0, the table body is empty and the line under it says "Showing 0
to 0 of 0 tasks". It was "only when the scoped pending total > 0" until then.

Table columns, in order:
| Inquiry No | Customer | Task | Date | Action |
- **Inquiry No**: `inquiries.code`, `fw-500`, linking to the inquiry show page
  only when the viewer passes `@can('view', $inquiry)` — otherwise plain text
  (an assignee without `inquiries.view` must not get a link that 403s).
- **Customer**: the inquiry's customer name as the inquiries module displays
  it elsewhere.
- **Task**: the type's `icon()` colored by its `tone()` + `label()`.
- **Date**: activity tasks — `date` in `config('nrems.date_format')` plus
  `timeLabel()` when present ("20 Apr 2026 · 11:00 AM"); rows dated today get
  a `badge badge-warning` "Today" (they're on top — say why). Document
  tasks — the em-dash `—` blank.
- **Action**: one green ✓ icon button (`fa-solid fa-check`, success styling
  consistent with the existing row-action buttons), shown for every row the
  query returned (the scope already filtered; the policy re-checks on POST).
  Document task → a real POST form with
  `onsubmit="return confirm({{ Js::from(...) }})"` — the house destructive-
  action pattern. Activity task → opens the shared conclusion modal (§6.3).

Below the table: the paginator links inside the existing
`.pagination-wrapper` styling, visually consistent with the module listings.

Strings: `lang/en/dashboard.php` (the dashboard's existing lang file) for the
card; reuse `lang/en/inquiries.php` where the string is the module's.

### 6.2 Follow Up History changes

`resources/views/inquiries/partials/history.blade.php` (one partial, both the
show page and the follow-up page — keep it that way):

- **Pending entry** (per §3.1, computed via the model helper): a `.fu-badges`
  line with `badge badge-warning` "Pending" and, when
  `@can('complete', $entry)`, the same ✓ button pair as the dashboard (confirm
  for document, modal for activity).
- **Completed entry** (`completed_at` not null): a meta line
  "Completed on 02 Sep 2026 · 4:15 PM by {name}" (new lang keys; the client's
  "show date and time on which date this is completed and who this task mark
  completed"). `completed_by` gone (user deleted) → omit the "by" half.
  The `conclusion`, when filled, renders as an escaped `.fu-note` line.
- The partial still runs **no queries**: callers add `completedBy:id,name`
  (new BelongsTo) to their eager loads; the show/follow-up controllers'
  eager-load lists are updated accordingly.

### 6.3 The conclusion modal + JS

One `<x-modal>` instance per page (dashboard, inquiry show, follow-up page —
extract a shared partial), wrapped in a real POST form, following the
`#notInterestedModal` template exactly: `<textarea name="conclusion" rows="4"
required>` with `old('conclusion','')`, a per-form error bag
(`complete_task`), reopen-after-422 via the server-rendered payload pattern,
footer `btn btn-outline` Cancel + `btn btn-success` "Mark Complete".

New `public/assets/js/nrems-pending-tasks.js`, shaped like
`nrems-inquiry-actions.js`: one config root (`#nrems_pending_tasks_module`),
per-button `data-*` payload carrying the server-generated POST URL + task
label, delegated listener, `bootstrap.Modal.getOrCreateInstance(...).show()`,
`retarget()` rewriting `form.action`, fields cleared on fresh open and
preserved on reopen, strings from `data-strings`, `createElement`/
`textContent` only.

Validation-error display: modal errors reopen the modal with the bag rendered;
the document-task confirm path's ValidationException surfaces as a dismissible
alert at the top of the card (flash-rendered), since there is no form field to
attach it to.

### 6.4 What NOT to build

No stat card, no DataTable, no search/filter row, no per-task edit, no
un-complete, no email — none were asked for.

## 7. Routes & permissions

### 7.1 Route

In `routes/modules/inquiries.php`, beside the other inquiry action routes:
`Route::post('follow-ups/{followUp}/complete',
[InquiryActionController::class, 'completeFollowUp'])
->name('follow_ups.complete');`
Controller method: authorize via the FormRequest, call
`FollowUpRecorder::complete()`, redirect **back** (`redirect()->back()` with
the fragment/page preserved) with the module-style flash success.

### 7.2 Permissions

One new permission: **`follow_ups.manage`** — "sees and may complete every
pending task" (§3.2). Seeded in `RolePermissionSeeder::PERMISSIONS` (now
**24**), granted to **Admin only**; Sales/Operations get nothing new (their
row-scope covers them). Register `FollowUpPolicy` however the app registers
`InquiryPolicy`. Update the `InquiryPolicy` docblock line "a child that has no
screen of its own has no permission of its own" — the child now has a screen
presence; say so rather than leaving the docblock false.

## 8. Decisions (client answers of 2026-09-02, restated)

- **D1** Activity tasks appear immediately and stay until concluded; ordering
  = today's on top, the rest ascending, dateless last ("If today's date then
  show on top else show on ascending order at last").
- **D2** Not Interested inquiries' tasks drop off the dashboard automatically
  (no data written; history untouched).
- **D3** Booking Confirm auto-completes ALL the inquiry's pending tasks with
  conclusion "Booking Confirm" — at confirm time going forward
  (BookingService) and via data migration for already-booked inquiries.
- **D4** Visibility/action scope = inquiry creator, inquiry Assigned To, task
  assignee, plus Admin (all tasks) — the client named all four.
- **D5** 10 rows per page with pagination (client: "Show 10 records and then
  pagination").
- **D6** (BA) Laravel paginator, not a DataTable — no search/sort was asked
  for and the dashboard stays light.
- **D7** (BA; **REVERSED by the client 2026-09-08 — the card is now ALWAYS
  rendered, reading 0 with an empty table when the viewer has nothing to do.**
  The paragraph below is kept as the record of what D7 said and why, not as a
  description of the screen.) The card renders only when the viewer has ≥1 pending task in
  scope.
- **D8** (BA) The booking-confirm bulk auto-complete writes no per-row
  activity rows; the interactive `complete()` path writes exactly one, with
  `->event('task_completed')`.
- **D9** (BA) The confirm popup copy is the client's sentence made
  grammatical: "Are you sure you want to mark this task as complete?".
- **D10** (BA) Completions are never reversed — cancellation of a booking
  does not reopen tasks.
- **D11** (BA) Conclusion is required for activity tasks (the popup exists to
  collect it), max 5000 chars; dropped silently for document tasks.

## 9. Tests & gates (Definition of Done)

House conventions apply (this is not a CR-8/9-override module):

- **Update pinned tests**: `DashboardTest` (section order now includes the
  card region; keep its `assertSeeInOrder` honest — note the card is absent
  for a user with no tasks, so order-assertions must seed one),
  `RolePermissionSeedTest` (23 → 24).
- **New focused Pest tests** (Feature, alongside `FollowUpTest`):
  the pending scope + ordering; row-scope visibility (admin all / creator /
  inquiry-assignee / task-assignee / stranger sees & completes nothing);
  document complete sets `date`=today + completion columns + one audit row
  with `event = task_completed`; activity complete requires conclusion;
  403 out-of-scope; already-completed double-submit; type-10 reject;
  booking-confirm auto-complete (and that cancel doesn't reopen);
  not-interested drop-off; data migration behavior if practical.
- Gates, run with `C:\wamp64\bin\php\php8.3.28\php.exe`: full
  `php artisan test` green (1033+ tests, 4 pre-existing skips allowed),
  `phpstan` level 6 = 0 errors, `pint --test` clean on the CR footprint,
  `migrate:fresh --seed` passes, second `db:seed` idempotent
  (permissions = 24). Never touch
  `tests/Feature/Projects/ProjectCrudTest.php` (corrupt in HEAD).
- **Nothing is committed** — the working tree already carries uncommitted
  CR-6..9; leave git alone.

---

## CR-20 — repair the activity-task Complete flow (client, 2026-09-12; fixes QA-1 ISSUE-1)

Verbatim: "For Personal Meeting, Site Visit and Follow Up Call pending task when user
will click on complete task ask Conclusion in popup for that task and once submit that
save in to that task use textarea for conclusion." Overrides: no test cases, no
policies.

This is the behavior CR-10 §1.3/§6.3 already specifies and built — QA-1's ISSUE-1
found it silently dead on every surface (dashboard, inquiry show, follow-up page).

- **U1 — Root cause (BA-verified)**: `inquiries/partials/task-action.blade.php:93`
  renders `data-task="{{ Js::from([...]) }}"` — `Js::from` emits a
  `JSON.parse('…')` EXPRESSION for inline-script contexts, so the ATTRIBUTE holds
  that literal wrapper string; `nrems-pending-tasks.js`'s `JSON.parse(attr)` throws
  into its swallowing catch and the ✓ no-ops. Fix = the house data-attribute pattern:
  `data-task="{{ json_encode([...]) }}"` ({{ }} = e() entity-escaping; JSON.parse
  client-side gets data back) — the same chain every CR-15+ payload uses. Sweep the
  partial (and any sibling) for OTHER Js::from-in-attribute instances; fix each and
  state the list. Inline-script Js::from uses stay.
- **U2 — Acceptance = the client's sentence, on all three surfaces**: click ✓ on a
  pending Personal Meeting / Site Visit / Follow Up Call → the conclusion popup opens
  RETARGETED at that task (form action = that task's complete route, label shows the
  task type), conclusion is a TEXTAREA (required, existing 5000 cap), submit → saved
  onto that task (completed_at/by + conclusion), journey shows "Completed on … by …" +
  the note, task leaves the pending list; empty conclusion → the popup's validation
  message; the reopen-after-rejected-submit path works (data-task-id matching). Doc-
  type tasks' one-click ✓ unchanged. The catch that swallowed the parse error gains a
  console.error or an honest comment so the next silent death is visible — implementer
  judgement, stated.
- **U3 — Gates + proof**: no pinned test covers this (QA-1 P4-39/P4-42 proved endpoint
  sound, client dead) — no test edits expected; full suite SOLO foreground (baseline
  1112/1108/0/4/4338, zero delta expected), phpstan 6 = 0, pint, node --check if JS
  touched. Real-app probes on ALL THREE surfaces incl. one full save + journey verify
  + empty-conclusion rejection + reopen path; use the QA-1 end-state data (7 inquiries
  live in nrems_html; snapshot-restore any row you mutate beyond adding completions —
  completions may stay, they are honest data). C:\wamp64\bin\php\php8.3.28\php.exe.
  No commits.
