# Dashboard — Requirements

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-19
> Unit granularity: `endpoint` · Legacy route: `GET /dashboard` (`DashboardController::index`)

**Confidence scale:** 🟢 CONFIRMED (read directly from code) · 🟡 INFERRED (pattern-based, may be wrong) · 🔴 GAP (needs human validation)

## Overview

The `dashboard` unit is the back-office analytics landing screen of TinyPOS (`tnx-pos`). A single `DashboardController::index` action renders `pages.dashboard` with three KPI counters (total products, total customers, today's completed orders), a sales-volume bar chart bucketed by **day / week / month**, and a **top-10 best-selling products** list. All figures are computed **live** on each request straight from the operational tables — there is no precomputed read model behind this screen. 🟢 (`app/Http/Controllers/DashboardController.php:28-122`)

Although `dashboard` is registered as a full Laravel resource, only `index` (`GET /dashboard`) is exercised; it is **not** the effective landing page — root `/` 301-redirects to `/pos`, and the framework `HomeController` is dead code (see BR-06). 🟢 (`routes/web.php:22,42`, `_reversa_sdd/code-analysis.md#module-dashboard`)

## Responsibilities

- Serve `GET /dashboard`, computing and rendering the analytics view `pages.dashboard`. 🟢 (`DashboardController.php:28,121`)
- Produce three KPI totals: `products` = `Product::count()`, `customers` = `Customer::count()`, `orders` = count of orders with `status = 'done'` created **today**. 🟢 (`:34-38`)
- Build a sales-volume chart whose bucketing depends on the `range` query parameter (`day` | `week` | `month`, default `month`), returning parallel `labelChart` / `valueChart` arrays. 🟢 (`:40-112`)
- Provide the fixed chart-range selector labels (`day`→"Day", `week`→"Week", `month`→"Month") and, for the `month` range, a `yearRange` string spanning the covered year(s). 🟢 (`:40-44,97`)
- Compute the top-10 products by total quantity sold, joining `order_product` to non-soft-deleted `products`. 🟢 (`:114-119`)
- **Out of scope for this unit (do not implement here):** the other resource verbs (`create/store/show/edit/update/destroy`) are auto-registered by `$router->resource('/dashboard', …)` but are unused stubs — the reimplementation should expose only the read action. 🟢 (`routes/web.php:42`)

## Business Rules

- **BR-01 — KPIs are live counts.** `products` and `customers` are unfiltered table counts; `orders` counts only rows where `status = 'done'` **and** `DATE(created_at) = CURDATE()` (server local date). Draft orders and prior-day orders are excluded from the KPI. 🟢 (`:34-38`, order lifecycle in `_reversa_sdd/state-machines.md`)
- **BR-02 — Sales chart counts orders, not revenue or units.** Each bucket value is `COUNT(*)` of `orders` rows whose `created_at` falls in the bucket window — a count of orders (regardless of `status`), not a sum of money or item quantity. 🟢 (`:52-99,102`)
- **BR-03 — Default range is `month`.** When `range` is absent, falsy, or not one of `day`/`week`/`month`, it is normalized to `month`. ✅ Fixed (2026-09-19): previously an out-of-whitelist value left the SQL `CASE` undefined (broken query); now explicitly whitelisted before the switch. 🟢 (`:47-50`)
- **BR-04 — Day buckets are three 6-hour store-open windows.** For `range=day` the chart has exactly three buckets — `Sáng` (06:00–12:00), `Trưa` (12:00–18:00), `Chiều` (18:00–24:00). Orders created 00:00–06:00 are intentionally excluded (store closed overnight). 🟢 (`:52-64`, `_reversa_sdd/flowcharts/dashboard.md`)
- **BR-05 — Week buckets are one-per-day; month buckets are contiguous 6-day windows.** For `range=week`, one bucket per calendar day from `startOfWeek` to `endOfWeek` (label = ISO date) 🟢. For `range=month`, windows labelled `dd/mm-dd/mm`; `yearRange` lists the distinct year(s) touched. ✅ Fixed 2026-09-22 — the `WHEN` clause's upper bound is now inclusive (`<=`, was `<`), so each window covers 6 days (`preDate` through `preDate+5`) and the cursor advance lands exactly on the next window's start: no day is dropped (was: the loop advanced the cursor 5 days for the window then 1 further day before the next iteration, so every 6th day — the 6th/12th/18th/24th/30th — fell in no bucket at all). For a 30-day month this yields 5 contiguous 6-day windows covering all 30 days exactly once. 🟢 (`:85-98`)
  - 🟡 **Separate, pre-existing gap (not fixed here):** for a month whose length isn't an exact multiple of 6 (e.g. 31-day or 28/29-day months), the loop's last iteration can start before `endOfMonth` and its window spills a few days into the **next** calendar month (e.g. a 31-day October's last bucket is `31/10-05/11`, attributing early-November orders to the October chart). This exists in both the pre-fix and post-fix code — it is a boundary-condition issue in the `while (strtotime($startMonth) <= strtotime($endMonth))` loop, independent of the 6th-day-drop bug. Tracked as a follow-up, not yet resolved.
- **BR-06 — Dashboard is not the landing page.** The effective post-login landing is `/pos` via a root 301 redirect; `HomeController::index` (which would have rendered the Encore dashboard builder) immediately redirects to `pos` and its builder block is unreachable dead code. `GET /dashboard` is reachable directly but must not be reintroduced as the home route. 🟢 (`routes/web.php:22`, `app/Http/Controllers/HomeController.php:12-40`)
- **BR-07 — Top-products list respects soft-deletes.** The top-10 join filters `products.deleted_at IS NULL`, so a discontinued (soft-deleted) product drops out of the ranking even if it has historical sales. Ranking is by `SUM(order_product.qty)` descending, capped at 10. 🟢 (`:114-119`, product soft-delete in `_reversa_sdd/state-machines.md`)
- **BR-08 — Bucket windows are string-formatted Carbon datetimes embedded in raw SQL.** The `CASE` expression is assembled in PHP by concatenating Carbon datetime values into the SQL string; correctness of the `day` range depends on Carbon 1.25 mutating `$startDay` in place across left-to-right concatenation. Confirmed correct, but fragile. 🟢 (`:59-61`, `composer.lock` `nesbot/carbon: 1.25.*`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance Criterion |
|----|-------------|----------|----------------------|
| RF-01 | Serve `GET /dashboard` behind the `admin` auth middleware, rendering `pages.dashboard`. | Must | Authenticated request returns the dashboard view with all view variables populated; anonymous request redirects to `auth/login`. 🟢 |
| RF-02 | Compute KPI `products` as the total product count. | Must | Value equals `Product::count()`. 🟢 |
| RF-03 | Compute KPI `customers` as the total customer count. | Must | Value equals `Customer::count()`. 🟢 |
| RF-04 | Compute KPI `orders` as today's completed-order count (`status='done'` AND `DATE(created_at)=CURDATE()`). | Must | Value counts only done orders created on the current server date. 🟢 |
| RF-05 | Accept a `range` query parameter (`day`/`week`/`month`), defaulting to `month`. | Must | Omitting `range` yields the month chart; `range=day`/`week` yield the day/week charts. 🟢 |
| RF-06 | For `range=day`, produce exactly three buckets `Sáng`/`Trưa`/`Chiều` covering 06–12 / 12–18 / 18–24. | Must | Chart has three labels in that order; a 07:00 order counts in `Sáng`, a 20:00 order in `Chiều`, a 03:00 order in none. 🟢 |
| RF-07 | For `range=week`, produce one bucket per day from `startOfWeek` to `endOfWeek`, labelled by ISO date. | Should | Labels are the week's dates; each bucket counts orders whose `DATE(created_at)` matches that day. 🟢 |
| RF-08 | For `range=month`, produce contiguous 6-day windows across the current month labelled `dd/mm-dd/mm`, plus a `yearRange` string. | Should | ✅ Fixed 2026-09-22 — windows are 6 days wide and tile the month with no gap (see BR-05); `yearRange` is the distinct covered year(s) joined by ` - `. A separate, pre-existing month-boundary spillover (non-6-multiple month lengths) remains open. 🟢 |
| RF-09 | Bucket values are order **counts** (`COUNT(*)`), pivoted into aligned `labelChart` / `valueChart` arrays; empty buckets are `0`. | Must | `valueChart[i]` is the order count for `labelChart[i]`; a bucket with no orders shows `0`, not a gap. 🟢 |
| RF-10 | Compute a top-10 best-selling products list by `SUM(order_product.qty)` desc, excluding soft-deleted products. | Should | List has ≤10 rows ordered by total qty; a soft-deleted product never appears. 🟢 |
| RF-11 | Expose the fixed chart-range label map (`day`→Day, `week`→Week, `month`→Month) and the currently selected `range` to the view. | Should | View receives `chartRange` and the active `range`. 🟢 |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | Screen requires an authenticated admin session (`admin` guard); no per-role restriction (any admin sees it). | `routes/web.php:24-28,42`, `config/admin.php:29`, `_reversa_sdd/permissions.md` | 🟢 |
| Performance | All aggregates are computed live per request (no cache, no precomputed table); cost grows with the `orders` / `order_product` table size. | `DashboardController.php:34-119` (direct queries, no cache facade) | 🟢 |
| Performance | KPI `orders` uses `whereRaw('DATE(created_at)=CURDATE()')`, a non-SARGable predicate that cannot use an index on `created_at`. | `:37` | 🟢 |
| Correctness | Bucket boundaries rely on Carbon 1.25 in-place mutation; a Carbon 2/3 (immutable-by-default `add*`) upgrade would silently break the `day` range. | `:59-61`, `composer.lock` | 🟢 |
| Maintainability | Raw SQL `CASE` is string-concatenated in PHP rather than built with the query builder/bindings. | `:52-99` | 🟢 |

> Inferred from the code. Validate the performance/scaling expectation (live aggregation, no cache) with the operations team; confirm the acceptable dashboard-load latency at production data volumes.

## Acceptance Criteria

```gherkin
Feature: Analytics dashboard

  Scenario: Authenticated user views the dashboard with default range
    Given I am an authenticated administrator
    When I request GET /dashboard
    Then I receive the pages.dashboard view
    And the KPI "products" equals the total product count
    And the KPI "customers" equals the total customer count
    And the KPI "orders" equals the count of done orders created today
    And the sales chart is bucketed by month (the default range)

  Scenario: Day range buckets orders into store-open windows
    Given orders were created today at 07:00, 14:00 and 20:00
    And an order was created today at 03:00
    When I request GET /dashboard?range=day
    Then the chart has three buckets "Sáng", "Trưa", "Chiều"
    And "Sáng" counts the 07:00 order
    And "Trưa" counts the 14:00 order
    And "Chiều" counts the 20:00 order
    And the 03:00 order is not counted in any bucket

  Scenario: Empty bucket renders as zero
    Given no orders were created in a given bucket window
    When I request the dashboard for that range
    Then that bucket's value in valueChart is 0
    And its label still appears in labelChart

  Scenario: Top products excludes soft-deleted products
    Given a soft-deleted product has historical order_product rows
    When I request GET /dashboard
    Then that product does not appear in the top-10 list

  Scenario: Anonymous access is rejected
    Given I am not authenticated
    When I request GET /dashboard
    Then I am redirected to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Rationale |
|-------------|--------|-----------|
| Render the dashboard behind auth (RF-01) | Must | The unit's single reachable action; nothing else works without it. |
| KPI counters (RF-02, RF-03, RF-04) | Must | The primary at-a-glance value of the screen; no fallback. |
| Sales chart with `range` bucketing + default month (RF-05, RF-06, RF-09) | Must | Core visualization; day-window semantics are a confirmed business rule. |
| Week / month bucketing + yearRange (RF-07, RF-08) | Should | Important but secondary to the default view; each has a working alternative range. |
| Top-10 products (RF-10) | Should | Useful merchandising insight, not on any critical transactional path. |
| Chart-range label map exposure (RF-11) | Should | UI affordance for switching ranges. |
| Other resource verbs (create/store/show/edit/update/destroy) | Won't | Auto-registered but unused stubs; do not implement. |
| Dashboard as landing page | Won't | Landing is `/pos`; `HomeController` builder is dead code. |

> Priority inferred from call frequency and position in the dependency chain (read-only analytics screen consumed by the operator, off the sales-critical path which is POS/orders).

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `routes/web.php:42` | `$router->resource('/dashboard', 'DashboardController')` (only `index` used) | 🟢 |
| `app/Http/Controllers/DashboardController.php:28-122` | `DashboardController::index` (KPIs, chart bucketing, top products, view render) | 🟢 |
| `app/Http/Controllers/DashboardController.php:34-38` | KPI totals (`Product::count`, `Customer::count`, today-done orders) | 🟢 |
| `app/Http/Controllers/DashboardController.php:47-100` | `range` switch — day / week / month `CASE` builders | 🟢 |
| `app/Http/Controllers/DashboardController.php:102-112` | `Order::select(DB::raw($query))` pivot into `labelChart`/`valueChart` | 🟢 |
| `app/Http/Controllers/DashboardController.php:114-119` | top-10 products query (`order_product` ⋈ `products`, soft-delete filter) | 🟢 |
| `routes/web.php:22`, `app/Http/Controllers/HomeController.php:12-40` | root→`/pos` redirect; dead Encore dashboard builder (BR-06) | 🟢 |
| `_reversa_sdd/flowcharts/dashboard.md` | verified control-flow of `index` (day-window trace) | 🟢 |
