# CR-17 — Dashboard: date-range filter + real data

Status: **specified 2026-09-10** — implementation assigned to an Opus agent; BA review to
follow. Reference design for the picker: the client's screenshot
(daterangepicker widget: input + preset sidebar + two-month calendar + Cancel/Apply).

## §0 Client-mandated overrides — do NOT "fix" back

1. **No new test files, no policies.** Existing pinned tests updated minimally and
   HONESTLY (§6 — several DashboardTest assertions are ALREADY broken by the client's
   own uncommitted dashboard edit; re-pin to the final real dashboard, quote every
   before/after).
2. **The client's uncommitted `dashboard.blade.php` edit is the BASE.** They already
   removed Total Revenue / Collection / Outstanding / Pending Agreements / Possession
   Due, the collection-trend chart, the grid-3 row and the footer-stats bar, merged the
   stat rows into ONE `grid-4` holding the three CR cards, and relabeled the pipeline to
   Projects/Units/Customers/Brokers/Users. Build ON that working-tree state; restore
   NOTHING they deleted. (Fix the one mismatched `<!--end::Secondary metrics-->` comment
   their merge left — comment-only.)
3. Dashboard has no lang-file prohibition; `lang/en/dashboard.php` exists — new
   user-facing dashboard strings go there (house convention). API/controller inline
   messages are fine where the module has none.

## §1 Client instruction (verbatim, 2026-09-10)

> in dashboard [screenshot] add like this daterange picker default show last 7 days date
> selected and show Total Bookings, Inquiries and Site Visits based on that filters
> default last 7 days and if select different filter and click on apply then make ajax
> request and show that card data changes.
> - Unit Availability not changed based on filter show all data.
> - Pending Tasks not changed based on filter.
> - Recent Bookings show last 5 booking and remove Amount and status column from list.
> - Sales Pipeline display total number of Projects, Units, Customers, Brokers and Users

## §2 The picker (N1–N4)

- **N1 — Library**: the daterangepicker already inside
  `public/assets/plugins/global/plugins.bundle.js` (verified present with moment.js and
  its CSS in plugins.bundle.css — ZERO new vendor files). Wired by a new
  `public/assets/js/nrems-dashboard.js` (the dashboard currently loads no page JS of its
  own).
- **N2 — Placement**: a filter row above the stat-cards grid (the wireframe has no
  filter control, so this is an addition by client instruction — one input styled like
  the app's `.filter-bar` controls, per the screenshot's look). It governs ONLY the
  three stat cards; say so in a comment.
- **N3 — Presets, exactly the screenshot's list**: Today, Yesterday, Last 7 Days,
  Last 30 Days, This Month, Last Month, **Last 3 Month**, **Last 6 Month**, Custom Range
  (the last two are not library defaults — define them: today-3-months+1day..today and
  today-6-months+1day..today, calendar months via moment subtract). Default selection =
  **Last 7 Days** = today-6 .. today (the library's preset definition). Cancel restores
  the previous applied range.
- **N4 — Display format** = the app's `config('nrems.date_format')` (dd-mm-YYYY) via the
  picker's `locale.format` — a recorded deviation from the screenshot's MM/DD/YYYY: this
  application draws ONE date format everywhere and the screenshot shows the library
  default, not a client-chosen format. The WIRE format is ISO `YYYY-MM-DD` regardless
  (`from`/`to` query params).

## §3 The three stat cards (N5–N8)

- **N5 — Semantics** (each by its CANONICAL date column, the ones every listing filter
  already uses):
  * **Total Bookings** = bookings with `booking_date` in [from,to], LIVE statuses only
    (`Booking::scopeLive()` — a cancelled booking no longer stands; flagged to client,
    one-line change if they want cancelled counted).
  * **Inquiries** = inquiries with `inquiry_date` in [from,to].
  * **Site Visits** = `follow_ups` rows of `FollowUpType::SiteVisit` (value 4) with
    `date` in [from,to] — `date` is the visit's SCHEDULED day (required for this type),
    counted whether or not concluded. Soft-delete scopes apply throughout.
- **N6 — First paint is SERVER-rendered** for the default last-7-days range (no
  JS-dependent initial numbers): DashboardController computes the three counts and the
  view renders them into the existing `.stat-value` slots. The placeholder
  `.stat-change` lines ("8 new today" etc.) are REMOVED — placeholder prose beside real
  numbers is a lie; nothing in the data backs them.
- **N7 — The ajax endpoint**: `GET /api/dashboard/stats?from=YYYY-MM-DD&to=YYYY-MM-DD`,
  new invokable `DashboardStatsController`, declared beside the dashboard route in
  `routes/web.php` inside the auth group (the `/api` prefix's 401-JSON behavior — house
  rule). **Auth-only, no permission gate — matching the dashboard route itself**, which
  is auth-only; the response is three integer counts, no entity data (flagged to
  client). Param hygiene: `from`/`to` must be plain strings (arrays → treated as
  absent), parsed strictly as `Y-m-d`, `from <= to`, both within [2000-01-01, today+1y];
  any violation → 422 JSON with a plain message. Response
  `{"bookings": int, "inquiries": int, "site_visits": int}` — data only.
- **N8 — Apply flow**: Apply (or preset click, which daterangepicker treats as apply) →
  fetch with `X-Requested-With` + `Accept: application/json` → update the three
  `.stat-value` nodes via `textContent`; a non-OK answer leaves the numbers and shows
  the error toaster (`NREMS.Toaster.show`, already shipped); a slow response may disable
  the input meanwhile. No URL building beyond the two query params on a server-rendered
  base URL (data attribute).

## §4 The unfiltered sections made real (N9–N12)

- **N9 — Unit Availability = real, ALL-TIME data** ("show all data"): the donut series
  and legend counts become real. Slices: **Available** = `units.status = available`
  minus derived-held; **Booked** = statuses booked/registered/possessed; **Held** =
  status held OR a live derived hold (`Inquiry::scopeHoldingAUnit` via whereExists —
  the UnitOptions definition, cited); a **Blocked** slice (blocked + management_quota)
  rendered ONLY when nonzero. Badge = real "{N} Units across {M} projects". The donut
  series moves from the hard-coded array in nrems-custom.js to data the page provides
  (data attribute JSON, `e(json_encode(...))`); nrems-custom.js's chart init reads it
  with the hard-coded array REMOVED (keep the container id `kt_dashboard_units_chart` —
  DashboardTest pins it).
- **N10 — Recent Bookings = real last 5**: newest first by `booking_date` desc then id
  desc, LIVE statuses only (the Status column is gone, so a cancelled row would be
  indistinguishable — flagged as decision). Columns: Booking ID / Customer / Project —
  Unit / Date (Amount and Status REMOVED per client — Amount never had data to show:
  bookings carry no money columns since CR-8). Cells: code, mainCustomer name (em-dash
  fallback), project — unit with em-dash join, `booking_date` via
  `config('nrems.date_format')`. Eager loads, no N+1. Zero bookings → an honest empty
  row ("No bookings yet." in lang), never the wireframe's fake rows.
- **N11 — Sales Pipeline = real totals**: Projects / Units / Customers / Brokers /
  Users via `Model::query()->count()` (global soft-delete scope = bin excluded; the
  UserController::stats() pattern). **Customers counts EVERY customers row — main +
  co-applicants** (they are all customers; flagged: mains-only is a one-line change).
  Keep the client's tone classes and order.
- **N12 — Pending Tasks: byte-identical.** No change, not filtered.

## §5 Controller shape

DashboardController stays invokable, gains private methods (stats/unitAvailability/
recentBookings/pipelineTotals) — small single-purpose queries, counts grouped where
they share a table (BookingController::stats() pattern). The api controller reuses the
SAME stat computation (one shared private static or a tiny support class — the page
and the endpoint must not be able to disagree; state where it lives).
PendingTaskTest:358 calls `__invoke()` with no arguments — if the signature gains
`Request`, update that call site minimally (quoted).

## §6 Pinned tests — honest re-pins (quote all before/after)

DashboardTest: `:46-48` (Total Revenue / ₹124.5 Cr / 384 placeholders — dead; replace
with real-data assertions: the three labels + seeded-count values rendered), `:74-82` +
`:98-107` (block-order needles include deleted `grid-3`/`footer-stats` — re-pin to the
FINAL section order incl. the new filter row), `:144` (deleted collection chart —
drop; keep `:145` units chart), `:41` survives only if the stat-card tag stays exactly
`<div class="stat-card">` — keep it so. `:158` (`assertDontSee('.html')`) — the new JS
and markup must not contain the literal string ".html" anywhere in the response.
PendingTaskTest `:201-205` — keep `grid-2 mb-4` and `dash-split` wrapper classes
exactly. No new test files.

## §7 Gates

Full suite (baseline 1112 tests / 4 skips — expect DashboardTest deltas ONLY from §6
re-pins, every one quoted; current HEAD+working-tree state has pre-broken assertions,
measure the before state honestly), phpstan level 6 = 0, pint on the footprint,
node --check. Probes on the REAL app: server-rendered card values match hand-run SQL
for last-7-days; picker shows the screenshot's 9 presets with Last 7 Days active by
default; Apply on a different range updates all three values with no navigation;
tampered `from[]=`/`to=garbage`/`from>to` → 422 + error toaster + numbers unchanged;
Unit Availability donut/legend/badge match SQL incl. a derived-hold case; Recent
Bookings shows ≤5 live rows, no Amount/Status columns; pipeline totals match counts;
Pending Tasks byte-identical (diff the section). PHP via
`C:\wamp64\bin\php\php8.3.28\php.exe`. No commits.

---

## §8 CR-17.1 — layout fix round (client, 2026-09-10)

Verbatim: "Move date picker right side on New inquiry line. / Site Visits card at Unit
Availability place and unit availability right side of Inquries." Overrides restated:
no test cases, no policies.

- **P1 — The picker joins the quick-actions line, right-aligned**: the filter row stops
  being its own block; the daterangepicker input sits on the SAME line as the New
  Inquiry quick-action card, pushed to the right (flex row, space-between; stacks
  vertically at phone width per the responsive discipline). The root id
  `nrems_dashboard_filter` and every data attribute/JS hook survive the move —
  nrems-dashboard.js must need NO change (verify; if it does, say why). The
  quick-actions wrapper keeps `class="quick-actions"` (DashboardTest pins it for a
  permissionless user); the permissionless case must still render the line with the
  picker (the picker is not permission-gated — the cards it drives aren't either).
- **P2 — The swap**: Site Visits (stat card) and Unit Availability (donut card) trade
  places. Top grid = Total Bookings, Inquiries, Unit Availability (the donut card as
  the third cell — it is taller; let the row stretch, don't shrink the donut);
  the `grid-2 mb-4` row below now holds the Site Visits stat card in Unit
  Availability's old slot. WRAPPER CLASSES DO NOT CHANGE: the section order
  quick-actions → grid-4 → grid-2 → pending tasks → dash-split stays, `grid-4 mb-4`
  and `grid-2 mb-4` stay verbatim (DashboardTest block-order + PendingTaskTest
  :201-205 pin them) — only the cards inside the two wrappers swap. The donut
  container id `kt_dashboard_units_chart` survives (pinned), and the ajax card update
  keeps working (the three data-stat slots move with their cards; the JS finds them by
  attribute, not position — verify).
- **P3 — Pinned tests**: the block-order re-pin from §6 placed `nrems_dashboard_filter`
  between quick-actions and grid-4 — update that needle honestly to the new position
  (inside the quick-actions line). Nothing else should move; quote any edit.
- **P4 — Gates**: full suite (baseline 1112/1108/0/4/4332 — deltas only from P3,
  quoted), phpstan 6 = 0, pint, node --check if JS touched. Probes on the real app:
  the picker renders right of New Inquiry on one line (desktop) and stacks at 390px
  with no horizontal scroll; Apply still updates all three cards in their NEW
  positions; permissionless user still sees the line + picker with no New Inquiry
  card; donut renders at its new size; Pending Tasks byte-identical.

---

## §9 CR-17.2 — Site Visits under Total Bookings (client, 2026-09-10)

Verbatim: "Move the 'Site Visits' card so it sits directly below the 'Total Bookings'
card in the left column, filling the blank space underneath Bookings. Keep the existing
card size, styling, colors, icon, typography, spacing, and border radius consistent with
the other statistic cards. Do not change the 'Inquiries' card or the 'Unit Availability'
chart. The final layout should have 'Total Bookings' on top and 'Site Visits' directly
underneath it, aligned to the same left and right edges. Make the responsive layout
behave consistently on smaller screens as well." Overrides restated: no tests, no
policies.

- **Q1 — Placement**: Site Visits moves INTO the top grid as a second row of the left
  column — Total Bookings row 1 / Site Visits row 2, same column, identical left+right
  edges; Inquiries stays row 1 column 2; the donut keeps its two-cell width and now
  spans BOTH rows (its height is what created the blank the client is filling). The
  card itself is untouched markup — same .stat-card anatomy, icon, tone, data-stat
  hook (the ajax update is attribute-driven and must keep working).
- **Q2 — DOM order = visual order everywhere**: Total Bookings, Site Visits, Inquiries,
  donut — so the phone-width single column reads Bookings with Site Visits directly
  underneath, matching the client's sentence on every screen. Grid placement (explicit
  rows/columns in the app-additions CSS tail) produces the desktop arrangement from
  that DOM order. Sweep the same width set CR-17.1 swept (360–1920) — no horizontal
  scroll, and at the ≤1200 band keep the arrangement coherent (donut full-width rule
  already exists; tiles keep pairing sensibly with Site Visits under Bookings).
- **Q3 — The now-empty `grid-2 mb-4` row is REMOVED**, not left as a hollow wrapper —
  an empty grid is a lie in markup. That breaks two pinned needles honestly:
  DashboardTest's block-order lists and PendingTaskTest:201-205's
  ['grid-2 mb-4', nrems_pending_tasks, dash-split] — re-pin both minimally to the real
  new order (grid-4 → pending tasks → dash-split), quoted before/after. Nothing else
  in either test moves.
- **Q4 — Gates**: full suite (baseline 1112/1108/0/4/4332 — deltas only from Q3,
  quoted), phpstan 6 = 0, pint; real-app probes: desktop shows Bookings over Site
  Visits sharing exact left/right edges (measure the boxes), Inquiries and donut
  visually unchanged (donut still 220px, series unchanged), Apply refreshes all three
  data-stat slots in the new arrangement, width sweep FITS, Pending Tasks
  byte-identical.

---

## §10 CR-17.3 — Cancelled Bookings card (client, 2026-09-10)

Verbatim: "Show Cancelled Booking card after Site Visits and show cancelled booking as
per filter." Overrides restated: no tests, no policies.

- **R1 — Placement**: a fourth stat card, "Cancelled Bookings", AFTER Site Visits —
  row 2, column 2 of the stat grid (under Inquiries), completing the 2×2 block beside
  the donut. DOM order becomes Bookings, Site Visits, Inquiries, Cancelled Bookings,
  donut — with the existing `grid-auto-flow: column` two-row template this lands
  r2c2 with NO new placement rules (verify; adjust minimally if the flow disagrees).
  Same `.stat-card` anatomy as its three siblings; icon/tone from the wireframe's own
  vocabulary for a cancelled/danger figure (e.g. `fa-ban`, `.stat-icon red` — the
  wireframe's Possession Due precedent). Responsive: the phone stack follows DOM order;
  re-sweep the CR-17.1/17.2 width set, all FITS.
- **R2 — Semantics (CORRECTED in review round)**: bookings whose
  **`cancellation_date` falls in [from,to]**, status cancelled — cancellations that
  happened in the window by the BUSINESS date. First specced as `cancelled_at`; the
  implementer's evidence reversed it: `cancellation_date` is the user-entered date on
  the CR-9 cancellation form — required, INDEXED, and the Cancellation listing's own
  default sort — while `cancelled_at` is a nullable, unindexed wall-clock timestamp on
  no screen. The other three cards count by business dates (booking_date /
  inquiry_date / follow_ups.date), so this keeps all four in one vocabulary and
  removes the NULL-uncounted hole. NOT booking_date + status: a booking made in March
  and cancelled this week is this week's cancellation. Flagged to client: if they
  meant "bookings FROM the window that are now cancelled", it is a two-line change.
- **R3 — Wiring**: `DashboardStats::forRange()` gains the fourth count (stays the ONE
  computation for page + endpoint); the endpoint response gains `"cancelled_bookings"`
  (int, data-only); the card's `.stat-value` carries `data-stat="cancelled_bookings"`
  so nrems-dashboard.js's attribute-driven update covers it — verify whether the JS
  iterates `[data-stat]` generically (zero change) or names keys (minimal addition,
  quoted). Server-rendered first paint like the other three; no `.stat-change` line.
- **R4 — Pinned tests, minimal honest updates**: the DashboardTest real-count test
  already seeds a cancelled booking INSIDE the window (to prove Total Bookings
  excludes it) — extend that same test to pin
  `data-stat="cancelled_bookings">1` (the seed now proves both exclusions AND the new
  card), plus the label. Quote before/after. Block-order needles unchanged (the card
  is inside the existing grid-4).
- **R5 — Gates**: full suite (baseline 1112/1108/0/4/4332 — deltas only from R4,
  quoted; run the suite SOLO, this box OOM-kills concurrent runs), phpstan 6 = 0,
  pint, node --check if JS touched. Real-app probes: card renders after Site Visits
  (r2c2, edges shared with Inquiries above it), Apply updates all FOUR slots
  (endpoint response quoted), a cancel-then-filter probe (cancelled today shows under
  Today, not under a window excluding today), width sweep FITS, Pending Tasks
  byte-identical.
