# Dashboard — Technical Design

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-19

**Confidence scale:** 🟢 CONFIRMED (read directly from code) · 🟡 INFERRED · 🔴 GAP

## Interface

### HTTP endpoint

Registered by `$router->resource('/dashboard', 'DashboardController')` inside the `['web','admin']`-guarded group (admin prefix is empty, so the path is site-root `/dashboard`). Only `index` is reachable in practice. 🟢 (`routes/web.php:42`, `config/admin.php:29`)

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/dashboard` | `range?: 'day'\|'week'\|'month'` (query, default `month`); session cookie | `pages.dashboard` HTML view | 200, 302¹ |

¹ `302` when the request is anonymous (redirected to `auth/login` by the `admin` middleware). 🟢

### View contract (`compact(...)` passed to `pages.dashboard`) 🟢 (`:121`)

| Variable | Type | Meaning |
|----------|------|---------|
| `total` | `array{products:int, customers:int, orders:int}` | KPI counters (products, customers, today's done orders). |
| `chartRange` | `array<string,string>` | Fixed range-selector map: `{day:'Day', week:'Week', month:'Month'}`. |
| `range` | `string` | The active range (`day`/`week`/`month`); echoes the request or the `month` default. |
| `yearRange` | `string\|null` | Year(s) spanned by the month chart (e.g. `"2026"`); `null` for day/week. |
| `labelChart` | `string[]` | Ordered bucket labels for the x-axis. |
| `valueChart` | `int[]` | Order counts aligned index-for-index with `labelChart`. |
| `products` | `Collection<{id,name,quantity}>` | Top-10 best-selling products (id, name, summed qty). |

### Internal symbols

| Symbol | Signature | Returns | Note |
|--------|-----------|---------|------|
| `DashboardController::index` | `()` | `View` | The entire unit; builds all view variables and renders. 🟢 (`:28`) |

## Main Flow

`DashboardController::index` 🟢 (`app/Http/Controllers/DashboardController.php:28-122`)

1. Set `header = __('Dashboard')` and a one-item breadcrumb. (`:30-33`)
2. Build `total`: `Product::count()`, `Customer::count()`, and `Order::where('status','done')->whereRaw("DATE(created_at)=CURDATE()")->count()`. (`:34-38`)
3. Define the static `chartRange` label map and initialise `yearRange = null`. (`:40-46`)
4. Resolve `range = request()->range ?: 'month'` and `switch` on it to build a raw SQL fragment `$query` of the form `COUNT(*) as quantity, CASE … END AS range_date` plus the ordered `$rangeData` label list: (`:47-100`)
   - **`day`** — anchor `$startDay = now()->startOfDay()->addHours(6)` (06:00) and `$endDay = now()->addDay()->startOfDay()` (next 00:00). Emit three `WHEN` branches; because Carbon `1.25` mutates `$startDay` in place and PHP concatenates left-to-right, the windows resolve to 06–12 (`Sáng`), 12–18 (`Trưa`), 18–`$endDay` (`Chiều`). `$rangeData = ['Sáng','Trưa','Chiều']`. (`:52-64`)
   - **`week`** — loop `startOfWeek → endOfWeek`, one `WHEN DATE(created_at)='Y-m-d' THEN 'Y-m-d'` per day, pushing each date onto `$rangeData`. (`:65-77`)
   - **`month`** — loop `startOfMonth → endOfMonth` in contiguous 6-day windows: each iteration clones the window start, advances 5 days, builds a `dd/mm-dd/mm` label, emits `WHEN DATE(created_at) >= start AND <= end THEN label`, records the year, then advances the cursor one further day to the next window's start (`:94`) — since the `WHEN` upper bound is inclusive, the cursor lands exactly on day 1 of the next window with no gap. ✅ Fixed 2026-09-22 (was `< end` — exclusive — which combined with the same cursor advance to skip every 6th day entirely). For a non-6-multiple month length, the last window can still spill a few days into the next calendar month — a separate, pre-existing boundary quirk, not yet resolved (see requirements.md BR-05). `yearRange = implode(' - ', array_unique([formYear, toYear]))`. (`:85-97`)
5. Run `Order::select(DB::raw($query))->groupBy('range_date')->pluck('quantity','range_date')->toArray()` → `$rangeGroup` (map of label → count). (`:102`)
6. Re-project `$rangeData` (the ordered label list) into `{key, value}` pairs, defaulting missing labels to `0`, then split into `labelChart` (`Arr::pluck 'key'`) and `valueChart` (`Arr::pluck 'value'`). This preserves label order and fills empty buckets with `0`. (`:104-112`)
7. Build top products with the query builder: `DB::table('order_product')->join('products', …)->whereRaw('`products`.`deleted_at` is null')->select(id,name,SUM(qty) as quantity)->groupBy('product_id')->orderBy('quantity','desc')->limit(10)->get()`. (`:114-119`)
8. `return $this->view('pages.dashboard', compact('total','chartRange','range','yearRange','labelChart','valueChart','products'))`. (`:121`)

## Alternative Flows

- **`range` omitted / falsy:** defaults to `month`. 🟢 (`:47`)
- **`range` set to an unexpected value:** ✅ Fixed (2026-09-19). Previously the `switch` matched no `case`, leaving `$query`/`$rangeData` undefined (PHP notice + broken query). Now `$range` is whitelisted to `day`/`week`/`month` before the switch, falling back to `month` — see Risks & Gaps. 🟢 (`:47-50`)
- **Order created 00:00–06:00 with `range=day`:** matches no `WHEN` branch → excluded from the chart (intentional; store closed overnight). 🟢 (`:59-61`)
- **Bucket with zero orders:** absent from `$rangeGroup`; re-projection defaults it to `0` so the label still appears. 🟢 (`:104-109`)
- **Soft-deleted product with sales:** filtered out of the top-10 by `deleted_at IS NULL`. 🟢 (`:117`)

## Dependencies

- **`Order` model / `orders` table** — source for the today-done KPI and every chart bucket (`COUNT(*)` grouped by the computed `range_date`). 🟢 (`app/Models/Order.php`, `:37,102`)
- **`Product` / `Customer` models** — KPI counts. 🟢 (`:35-36`)
- **`order_product` pivot + `products` table** — top-10 aggregation via the query builder, honouring product soft-deletes. 🟢 (`:114-119`)
- **`Carbon` (`nesbot/carbon: 1.25.*`)** — all bucket boundary datetimes; the `day` range depends on its mutating `addHours`. 🟢 (`:52-99`, `composer.lock`)
- **`Illuminate\Support\Arr` + `DB` facade** — array pivot and raw SQL. 🟢 (`:21-22,102-119`)
- **Encore\Admin layout + `pages.dashboard` Blade view** — rendering surface (`$this->view(...)`). 🟢 (`:121`)
- **`admin` auth middleware** — gates the route (`auth` unit). 🟢

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| Live per-request aggregation (no cache, no precomputed table for the dashboard). | `DashboardController.php:34-119` (no cache/queue usage) | 🟢 |
| Bucketing done by concatenating a raw SQL `CASE` in PHP rather than the query builder. | `:52-99` | 🟢 |
| Chart values are order **counts** (`COUNT(*)`), not revenue or item quantity. | `:59,69,84,102` | 🟢 |
| Day range fixed to three store-open 6h windows, dropping the 00:00–06:00 overnight window. | `:52-64`, `_reversa_sdd/flowcharts/dashboard.md` | 🟢 |
| Top-products ranking honours soft-deletes (discontinued products vanish from the list). | `:117` | 🟢 |
| Correctness of the `day` `CASE` relies on Carbon 1.25 in-place mutation + left-to-right concat. | `:59-61`, `composer.lock` | 🟢 |

## Internal State

Stateless — the unit persists nothing and holds no cross-request state. Every KPI, chart bucket and product ranking is recomputed from the `orders`, `order_product`, `products` and `customers` tables on each request; there is no dashboard cache or materialised view. 🟢 (`:34-119`)

## Observability

- No logs, metrics or traces are emitted by this action. 🟢 (no logging calls in `DashboardController.php`)
- Encore\Admin `operation_log` is globally disabled (`operation_log.enable = false`), so dashboard views are not audit-logged either. 🟢 (`config/admin.php`, `_reversa_sdd/code-analysis.md`)
- 🔴 No slow-query instrumentation despite the non-SARGable `DATE(created_at)=CURDATE()` KPI and full-table chart scans — a reimplementation should add timing/metrics if dashboard latency matters at scale.

## Risks & Gaps

- ✅ **Fixed: `range` fallback (2026-09-19).** An unexpected `range` value (e.g. `?range=year`) used to leave `$query`/`$rangeData` undefined (broken query / PHP notice). Now `$range` is explicitly whitelisted to `day`/`week`/`month` before the switch, falling back to `month`. (`:47-50`)
- 🟢 **Carbon-version coupling.** The `day` bucket boundaries are correct only under mutating Carbon (`1.25`). Upgrading to Carbon 2/3 (immutable-by-default arithmetic) would silently collapse all three windows to 06:00–12:00. Rewrite with explicit per-branch `clone`/immutable instances when porting. (`:59-61`)
- 🟢 **Raw string SQL, not parameter-bound.** `range` is not interpolated, but the pattern (`DB::raw` string concatenation of datetimes) is brittle; prefer the query builder with bindings on reimplementation. (`:52-99`)
- 🟡 **Chart semantics are order counts.** Stakeholders may expect revenue or units sold; confirm the chart's intended metric before re-labelling. (`:102`)
- 🟢 **Dead imports.** `App\Models\OrderProduct` (no such model) and `Cassandra\Custom` are unused erroneous imports — do not carry them forward. (`DashboardController.php:8,12`)
