# Admin Dashboard Home API

The landing screen after admin login (BRD §13 "لوحة التحكم الرئيسية" / dashboard home) — a single endpoint that feeds every stat card, "next up" widget and quick-glance counter on the home screen, scoped by one shared duration quick filter. It is deliberately **not** a detailed report: each figure is a lean total or a top-3/top-5 list. For drill-down detail (charts, breakdowns, paginated ledgers), the dashboard's cards should deep-link into the dedicated report endpoints — see [reports-revenue.md](./reports-revenue.md) and [reports-admin.md](./reports-admin.md).

- **Base path:** `{{base_url}}/admin` (e.g. `http://localhost:8000/api/v1/admin`)
- **Auth:** `Authorization: Bearer {{admin_token}}`
- **Middleware stack:** `api, set_locale, cors, auth:api, active, user_type:super_admin,supervisor, rbac.auto`
- **RBAC permission:** `dashboard.index` (seeded by `PermissionSeeder`; super admins pass automatically, other roles must be granted it)
- **Source files:**
  - Controller: [DashboardController.php](../../app/Http/Controllers/Api/V1/Admin/DashboardController.php)
  - Service: [DashboardService.php](../../app/Services/Admin/DashboardService.php)
  - Request: [DashboardRequest.php](../../app/Http/Requests/Admin/Dashboard/DashboardRequest.php)
  - Revenue/top-sellers helpers reused from: [RevenueReportService.php](../../app/Services/Admin/RevenueReportService.php) (`range()`, `totals()`, `topSellers()`)
  - Tests: [DashboardTest.php](../../tests/Feature/Admin/DashboardTest.php)

---

## The one rule that matters most: not every section respects the duration filter

This is the single biggest gotcha on this endpoint. Half the response answers "how much happened **in the selected period**" and the other half answers "what does the system look like **right now**" — the quick filter (`preset`/`from`/`to`) only changes the first half.

| Respects the duration filter | Always "right now", ignores the filter |
|---|---|
| `users.new_in_period` | `users.total`, `users.active`, `users.by_type` |
| `learning.new_enrollments`, `learning.completed_enrollments` | `learning.courses_total`, `learning.courses_published`, `learning.programs_total`, `learning.programs_published` |
| `revenue[]` (all of it) | — |
| `orders.new_in_period` | `orders.pending` |
| `consultations.new_in_period` | — |
| `top_sellers` (all of it) | — |
| `top_achievers` | — |
| — | `moderation` (all of it) |
| — | `upcoming` (all of it — forward-looking from "now", not bounded by `to`) |
| — | `today` (all of it — literally today's date, even if the filter says `this_year` or a past custom range) |

Render the period-scoped tiles inside whatever "Showing: This Month ▾" quick-filter control you build, and render the right-now tiles (moderation, upcoming, today) outside it, always — changing the quick filter should visibly leave them unchanged. `system` is static app metadata, not user data, and is unaffected either way.

---

## `GET /admin/dashboard`

### Query parameters

All optional — same convention as the revenue report.

| Param | Type | Description |
|---|---|---|
| `from` | `Y-m-d` | Period start (inclusive, from `00:00:00`) |
| `to` | `Y-m-d` | Period end (inclusive, through `23:59:59`). Must be `>= from` or the request is rejected |
| `preset` | `today` \| `this_month` \| `last_month` \| `this_year` | Convenience period; ignored if `from` or `to` is sent |
| `group_by` | `day` \| `month` | Only affects `data.trend` below; auto-picked (`day` for ranges ≤ 62 days, else `month`) when omitted. Rejected if `day` is explicit and the resolved range exceeds 366 days |
| `include_trend` | `0` \| `1` | Default `0`. `1` adds `data.trend`, the shared dated series for the four activity cards — see [Dated trend](#dated-trend-include_trend1) below |

There is no `source`, `trainer_id`, `country_id` or any other narrowing filter on this endpoint — it's a whole-system summary. Use the dedicated report endpoints for filtered/drilled-down views.

### Period resolution

Identical rule to the revenue report (both share `RevenueReportService::range()`):

1. If `from` and/or `to` is present, they win — `preset` is ignored.
   - `to` alone → `from` defaults to the **start of the month containing `to`**, not "30 days back."
   - `from` alone → `to` defaults to today.
2. Otherwise `preset` applies.
3. With neither, the dashboard defaults to **`this_month`**.

The resolved range is echoed back under `period` — render the active range from the response, not by re-deriving it client-side. With `include_trend=1`, `period` additionally carries `group_by` and `timezone` (the value of `config('app.timezone')`, currently `UTC`).

### Response shape

```json
{
  "status": "success",
  "message": "Dashboard metrics retrieved",
  "data": {
    "period": { "from": "2026-09-01", "to": "2026-09-22" },
    "users": {
      "total": 4210,
      "active": 3980,
      "new_in_period": 186,
      "by_type": { "trainers": 42, "users": 4150, "supervisors": 18 }
    },
    "learning": {
      "courses_total": 96,
      "courses_published": 71,
      "programs_total": 23,
      "programs_published": 15,
      "new_enrollments": 340,
      "completed_enrollments": 128
    },
    "revenue": [
      {
        "currency": { "id": "6f1e...-country-uuid", "code": "EGP", "symbol": "E£" },
        "gross_revenue": 12500.00,
        "shipping_fees": 340.00,
        "items_revenue": 12160.00,
        "payments_count": 84,
        "average_payment": 148.81,
        "previous_period": { "gross_revenue": 9800.00, "payments_count": 61 },
        "change_percent": 27.55
      }
    ],
    "orders": { "new_in_period": 34, "pending": 9 },
    "consultations": { "new_in_period": 21 },
    "moderation": { "hidden_reviews": 3, "unread_contact_messages": 7 },
    "top_sellers": {
      "courses": [
        { "id": "00000000-0000-0000-0000-000000000001", "name": "Financial Management Essentials", "currency": { "id": "6f1e...", "code": "EGP", "symbol": "E£" }, "revenue": 2400.00, "payments_count": 16 },
        { "id": "00000000-0000-0000-0000-000000000002", "name": "Excel for Analysts", "currency": { "id": "6f1e...", "code": "EGP", "symbol": "E£" }, "revenue": 1800.00, "payments_count": 12 }
      ],
      "programs": [
        { "id": "00000000-0000-0000-0000-000000000003", "name": "Leadership Track", "currency": { "id": "6f1e...", "code": "EGP", "symbol": "E£" }, "revenue": 3100.00, "payments_count": 12 }
      ],
      "books": [
        { "id": "00000000-0000-0000-0000-000000000004", "name": "Advanced Bookkeeping", "currency": { "id": "6f1e...", "code": "EGP", "symbol": "E£" }, "revenue": 900.00, "units": 18 }
      ]
    },
    "upcoming": {
      "sessions": [
        { "id": "00000000-0000-0000-0000-000000000010", "owner_type": "course", "owner_name": "Financial Management Essentials", "mode": "online", "starts_at": "2026-09-22T18:00:00+02:00" },
        { "id": "00000000-0000-0000-0000-000000000011", "owner_type": "program", "owner_name": "Leadership Track", "mode": "onsite", "starts_at": "2026-09-29T09:00:00+02:00" }
      ],
      "consultations": [
        {
          "id": "00000000-0000-0000-0000-000000000020",
          "scheduled_at": "2026-09-22T15:00:00+02:00",
          "user": "Mona Farouk",
          "trainer": "Sara Al-Mansouri",
          "consultation_type": "Tax Consultation"
        }
      ]
    },
    "today": { "sessions": 2, "lessons": 1, "consultations": 3 },
    "top_achievers": [
      { "user": { "id": "c9a1...-user-uuid", "name": "Omar Khaled" }, "certificates_count": 2 },
      { "user": { "id": "77bd...-user-uuid", "name": "Mona Farouk" }, "certificates_count": 1 }
    ],
    "system": { "php_version": "8.3.14", "laravel_version": "13.0.0", "environment": "production" }
  }
}
```

### Field reference

#### `period`

`{ from, to }` by default. With `include_trend=1`, also `group_by` and `timezone`.

#### `users`

| Field | Type | Meaning |
|---|---|---|
| `total` | integer | All users, any type — not period-scoped |
| `active` | integer | `users.is_active = true` — not period-scoped |
| `new_in_period` | integer | Users whose `created_at` falls in the selected period |
| `by_type.trainers` | integer | `user_type = trainer` |
| `by_type.users` | integer | `user_type = user` (regular trainees) |
| `by_type.supervisors` | integer | `user_type = super_admin` **plus** `user_type = supervisor`, combined into one bucket |

`by_type`'s three buckets always sum to `total` (every `UserType` case is covered).

#### `learning`

| Field | Type | Meaning |
|---|---|---|
| `courses_total` / `programs_total` | integer | All rows, any `content_status`, any `is_visible` — not period-scoped |
| `courses_published` / `programs_published` | integer | `content_status = approved` — not period-scoped. **Not the same as "visible to trainees"** — a course can be `approved` but still have `is_visible = false`; use the courses/programs list endpoint's own filters for that distinction |
| `new_enrollments` | integer | `enrollments.enrolled_at` in the selected period — **courses and programs combined** into one number |
| `completed_enrollments` | integer | `enrollments.completed_at` in the selected period — combined the same way |

#### `revenue[]`

Identical shape to the revenue report's `summary[]` — see [reports-revenue.md → `summary[]` field reference](./reports-revenue.md#summary--one-row-per-currency) for the full breakdown of `gross_revenue`, `shipping_fees`, `previous_period`, and the `change_percent: null` rule. Same currency-partitioning rule applies: **never sum across `currency` entries.**

#### `orders`

| Field | Type | Meaning |
|---|---|---|
| `new_in_period` | integer | Orders (any status, any type) created in the selected period |
| `pending` | integer | Orders currently `status = processing` — **not period-scoped**, the live queue size right now |

#### `consultations`

| Field | Type | Meaning |
|---|---|---|
| `new_in_period` | integer | Consultation bookings created in the selected period (booking creation time, **not** the consultation's scheduled time) |

#### `moderation` — not period-scoped

| Field | Type | Meaning |
|---|---|---|
| `hidden_reviews` | integer | Reviews with `status = hidden` right now |
| `unread_contact_messages` | integer | Contact messages with `is_read = false` right now |

These are queue sizes for an admin's moderation to-do list, not activity counts — a review hidden last month still counts today if it hasn't been dealt with.

#### `top_sellers`

Best-selling courses, programs and books **in the selected period**, capped to the top **3** each (vs. the full revenue report's top 10). Same row shape as that report's `top_items` — see [reports-revenue.md → `top_items`](./reports-revenue.md#top_items) for the full explanation, including the ⚠️ note that `top_sellers.books[].revenue` is summed from order line items and is **not directly comparable** to `revenue[].gross_revenue`.

Unlike the full revenue report, `top_sellers` has **no `consultations` list** — consultations aren't "sold" the same way and don't fit a 3-card layout alongside courses/programs/books. If you need top consultations, call `GET /admin/reports/revenue` and read `top_items.consultations`.

A list can have fewer than 3 entries (or be empty) if fewer than 3 items had any revenue in the period — don't pad the UI expecting exactly 3.

#### `upcoming` — not period-scoped, forward-looking from "now"

Two "what's next" widgets, unrelated to the duration filter — they always show what's coming up starting from the moment the request is made, regardless of whether the quick filter says `this_month` or a custom range in the past.

**`upcoming.sessions[]`** — the next **5** scheduled class sessions across **all** live courses and training programs, nearest-first:

| Field | Type | Meaning |
|---|---|---|
| `id` | uuid | The session's public id |
| `owner_type` | `course` \| `program` | What kind of session this is |
| `owner_name` | string \| null | The course/program's name in the current locale. Null only in the rare case the owning course/program row no longer exists (sessions reference their owner without a DB foreign key) |
| `mode` | `online` \| `onsite` | |
| `starts_at` | ISO 8601 string | Combined `session_date` + `start_time` in the business timezone |

Only sessions whose derived status is `upcoming` (see `SessionStatus`) are included — a session happening right now, or one still missing its calendar (Phase-A placeholder with no date/time yet), is excluded. ⚠️ **Known limit:** the query scans at most the 100 nearest future-dated candidate rows before filtering to `upcoming` status and taking 5. In the extremely unlikely case that a single day has 100+ scheduled sessions ahead of enough genuinely-upcoming ones, a further-out upcoming session could be missed from this list. This does not affect `today`'s counts, which query directly.

**`upcoming.consultations[]`** — the next **5** upcoming consultation bookings, nearest-first:

| Field | Type | Meaning |
|---|---|---|
| `id` | uuid | The booking's public id |
| `scheduled_at` | ISO 8601 string | |
| `user` | string \| null | The booking user's full name |
| `trainer` | string \| null | The assigned trainer's full name |
| `consultation_type` | string \| null | In the current locale |

Only bookings with `status = upcoming` **and** `scheduled_at` in the future are included — a booking scheduled in the future but already marked `completed`/`cancelled`/`no_show` (e.g. by an admin correction) will not appear here.

#### `today` — not period-scoped, literally today's calendar date

Three quick counters for "what's on today," regardless of the selected quick filter:

| Field | Type | Meaning |
|---|---|---|
| `sessions` | integer | Live-**course** class sessions scheduled for today (any status — includes ones already finished or running now, not just upcoming ones) |
| `lessons` | integer | Training-**program** class sessions scheduled for today, counted the same way. ⚠️ Despite the key name, this is **not** related to `CourseLesson` content (recorded lesson videos/quizzes) — course lesson content has no schedule in this system. `lessons` here specifically means "today's program sessions," kept as a separate counter from `sessions` (course sessions) so the two owner types don't get summed together |
| `consultations` | integer | Consultation bookings scheduled for today, any status |

#### `top_achievers`

Top **3** users ranked by number of **valid** certificates (courses + programs combined) issued **within the selected period**:

| Field | Type | Meaning |
|---|---|---|
| `user.id` | uuid | |
| `user.name` | string | |
| `certificates_count` | integer | Valid certificates issued to this user in the period. `revoked` certificates never count |

Can return fewer than 3 rows (or be empty) if fewer than 3 distinct users earned a certificate in the period.

#### `system`

Static app metadata — `php_version`, `laravel_version`, `environment`. Not user data; useful for an "About / Diagnostics" footer, not a stat card.

#### Dated trend (`include_trend=1`)

```json
{
  "data": {
    "period": { "from": "2026-09-01", "to": "2026-09-22", "group_by": "day", "timezone": "UTC" },
    "trend": [
      { "period": "2026-09-01", "new_users": 3, "new_enrollments": 5, "new_orders": 1, "new_consultations": 0 },
      { "period": "2026-09-02", "new_users": 0, "new_enrollments": 2, "new_orders": 0, "new_consultations": 1 }
    ]
  }
}
```

One shared, zero-filled series for the four period-scoped cards — `users`, `learning`, `orders`, `consultations` from the table at the top of this doc. Every bucket from `period.from` through `period.to` is present, oldest first, `YYYY-MM-DD` keys for `group_by=day` and `YYYY-MM` for `group_by=month`; a zero-activity bucket is `0`, not omitted.

| `trend[]` field | Sums to |
|---|---|
| `new_users` | `users.new_in_period` |
| `new_enrollments` | `learning.new_enrollments` |
| `new_orders` | `orders.new_in_period` |
| `new_consultations` | `consultations.new_in_period` |

Each field is the **count in that bucket**, like the revenue report's `series[].revenue` — plot it directly rather than a running total; the last point is that bucket's count, not the period total. `trend` is omitted entirely when `include_trend` is `0` or absent, so a plain dashboard load doesn't pay for the four extra grouped queries.

---

## Caching

The whole response is cached for **60 seconds** per exact combination of query parameters sent (keyed by the raw validated `from`/`to`/`preset`/`group_by`/`include_trend` values, not the resolved period — so a summary-only request and an `include_trend=1` request for the same effective period never share a cache entry) and is invalidated early — before the 60 seconds is up — the moment any underlying table changes (users, courses, programs, enrollments, payments, orders, consultation bookings, reviews, contact messages, class sessions, certificates), the same smart-cache mechanism the rest of the admin API uses. Practically: figures can lag a live create/update by up to a few seconds within that window, never longer, and never stale after a real change. Two requests that resolve to the same effective period but were sent with different literal parameters (e.g. no params vs. `?preset=this_month`) are cached as separate entries.

---

## Frontend notes

1. **Split your layout along the duration-filter line first** (see the table at the top) — it's the most consequential design decision on this screen. A single "Refresh" affordance that only refetches doesn't fix a quick-filter dropdown that visually implies it controls the whole page when half the tiles won't move.
2. **`revenue` and `top_sellers` are currency-partitioned.** Never sum across `currency` blocks — render per-currency cards/tabs if more than one currency is active. See [reports-revenue.md's frontend notes](./reports-revenue.md#frontend-notes) for the full currency-handling guidance, which applies here unchanged.
3. **`users`, `learning`, `orders`, `consultations` now have a dated trend, but still no `change_percent`.** Request `include_trend=1` and plot `data.trend[].new_users`/`new_enrollments`/`new_orders`/`new_consultations` for the sparklines (see [Dated trend](#dated-trend-include_trend1) above). There's still no built-in "+12% vs last period" delta for those four — only `revenue` has `change_percent`. If the design calls for a period-over-period delta on the other cards, compute it by diffing two dashboard calls (current vs. previous preset) client-side, or treat it as a gap and flag it for a future endpoint change.
4. **`today.lessons` is not course-lesson content.** If your design has a literal "Lessons" tab/icon elsewhere in the app (e.g. course curriculum), don't reuse that icon/label for this counter without a tooltip — it means "today's training-program sessions." Consider labeling it "Program Sessions Today" in the UI even though the API key is `lessons`.
5. **`upcoming` and `today` are system-wide**, not scoped to a specific trainer — there's no per-trainer "my schedule" filter on this endpoint. A trainer-facing "my day" widget needs a different (trainer-guard) endpoint, not this one.
6. **Cards should deep-link, not duplicate.** This endpoint intentionally has no breakdowns/series/pagination — wire each tile's "View details" / "See all" action to the matching report or list screen (`top_sellers.courses` → courses report, `upcoming.consultations` → consultation bookings list, `moderation.hidden_reviews` → reviews list filtered `status=hidden`, `moderation.unread_contact_messages` → contact messages list filtered `is_read=false`, etc.) rather than trying to grow this endpoint into a report.
7. **Empty/short lists are normal, not an error state.** `top_sellers.*`, `upcoming.*` and `top_achievers` can all legitimately return fewer items than their cap (or `[]`) — design the empty state (e.g. "No sessions scheduled" / "No certificates yet this period") rather than assuming a fixed count.
8. **Numbers are numbers, timestamps are ISO 8601** — same formatting rules as every other admin report endpoint.
9. **One permission gates the whole screen.** `dashboard.index` is a single on/off switch for this entire response — there's no per-tile permission. If an admin's role also lacks e.g. `reports.revenue`, the `revenue`/`top_sellers` tiles here will still render (the dashboard doesn't re-check report-specific permissions), but tapping through to `/admin/reports/revenue` for detail will then `403` — handle that gracefully on navigation, not by hiding the dashboard tile.

---

## Error codes

| HTTP Status | Trigger |
|---|---|
| `401 Unauthorized` | Missing or expired admin bearer token |
| `403 Forbidden` | Admin role lacks `dashboard.index` (response carries an `X-Missing-Permission` header naming it) |
| `422 Unprocessable Entity` | `to` before `from`; `from`/`to` not `Y-m-d`; `preset` outside its allowed values |

---

## Example requests

```bash
# This month's dashboard (default period)
curl -H "Authorization: Bearer {{admin_token}}" \
  "{{base_url}}/admin/dashboard"

# Today only
curl -H "Authorization: Bearer {{admin_token}}" \
  "{{base_url}}/admin/dashboard?preset=today"

# Custom range
curl -H "Authorization: Bearer {{admin_token}}" \
  "{{base_url}}/admin/dashboard?from=2026-01-01&to=2026-09-22"
```

Postman: see "Dashboard" → "List All Dashboard" in [admin.md](./admin.md#dashboard).

---

## Not included (yet)

- `change_percent` (period-over-period delta) for any section other than `revenue` — `users`/`learning`/`orders`/`consultations` have a dated `trend` (see above) but no computed delta
- A per-trainer / "my schedule" variant of `upcoming`/`today`
- Per-tile RBAC (the whole response is gated by one `dashboard.index` permission)
- CSV/Excel export (not applicable to a summary screen, but noted for consistency with the other report docs)
- Configurable widget selection/layout (all sections always compute and return, even ones the frontend may choose not to render)
