# Attendance & Completion Reports API — Admin Dashboard

Read-only reports for the admin "Reports" screen (BRD §13 "التقارير"): one row per course or training program, with enrolment, completion, attendance and (courses only) quiz-performance metrics. Build one table screen with two tabs (Courses / Programs) on top of these two endpoints.

- **Base path:** `{{base_url}}/admin` (e.g. `http://localhost:8000/api/v1/admin`)
- **Auth:** `Authorization: Bearer {{admin_token}}` on every request
- **Middleware stack:** `api, set_locale, cors, auth:api, active, rbac.auto`
- **RBAC permissions:** `reports.courses`, `reports.programs` (seeded by `PermissionSeeder`; super admins pass automatically, other roles must be granted them)
- **Source files:**
  - Controller: [ReportController.php](../../app/Http/Controllers/Api/V1/Admin/ReportController.php)
  - Request: [EntityReportRequest.php](../../app/Http/Requests/Admin/Report/EntityReportRequest.php)
  - Service: [ReportService.php](../../app/Services/Admin/ReportService.php)
  - Dated trend calculator: [EnrollmentTrendService.php](../../app/Services/Admin/Support/EnrollmentTrendService.php)
  - Tests: [AttendanceCompletionReportTest.php](../../tests/Feature/Admin/AttendanceCompletionReportTest.php), [CourseProgramReportTrendTest.php](../../tests/Feature/Admin/CourseProgramReportTrendTest.php)

---

## Endpoints at a glance

| Tab | Method | Endpoint | Extra filter |
|---|---|---|---|
| Courses | `GET` | `{{base_url}}/admin/reports/courses` | `course_type` |
| Programs | `GET` | `{{base_url}}/admin/reports/programs` | — |

Both accept the same shared query parameters and return the same row shape (only the id key differs).

---

## Query parameters

All optional. Send only the ones the user has set.

| Param | Type | Applies to | Description |
|---|---|---|---|
| `page` | integer | both | Page number, default `1` |
| `per_page` | integer | both | Rows per page, default `15`, clamped to `1–100` |
| `search` | string | both | Partial match on the item name (any locale) |
| `trainer_id` | uuid | both | Only items owned by this trainer (the trainer's uuid, same value used elsewhere in the admin API) |
| `course_type` | `live` \| `recorded` | courses only | Restrict to one course type |
| `preset` | `today` \| `this_month` \| `last_month` \| `this_year` | both | Selects the window for the added `period_metrics`/`trend` fields below — see [Dated period_metrics/trend](#dated-period_metricstrend) |
| `from`, `to` | `Y-m-d` | both | Same convention as the revenue report; `to >= from` |
| `group_by` | `day` \| `month` | both | Bucket size for `trend`. Auto-chosen when omitted; `day` rejected for resolved ranges over 366 days |
| `include_trend` | `0` \| `1` | both | Default `0`. `1` adds each row's `trend` array; `period_metrics` appears whenever any of `preset`/`from`/`to`/`group_by` is sent too, even with `include_trend=0` |

Rows are sorted newest first. There is no `sort` parameter and no export. `search`/`trainer_id`/`course_type`/`page`/`per_page` decide **which rows** appear on the page; the period parameters only scope each row's added `period_metrics`/`trend` and never hide an inactive row.

---

## Response

Standard envelope; the paginator sits under `data`.

### `GET /admin/reports/courses`

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "current_page": 1,
    "data": [
      {
        "course_id": "00000000-0000-0000-0000-000000000001",
        "name": "Financial Management Essentials",
        "trainer": "Sara Al-Mansouri",
        "total_enrolled": 40,
        "completed": 10,
        "completion_rate": 25,
        "average_progress_percent": 61.5,
        "sessions_total": 8,
        "average_attendance_rate": 82.5,
        "quiz_attempts_total": 57,
        "quiz_average_score": 74.3,
        "quiz_pass_rate": 68.4
      }
    ],
    "per_page": 15,
    "total": 1,
    "last_page": 1,
    "from": 1,
    "to": 1,
    "next_page_url": null,
    "prev_page_url": null
  }
}
```

`GET /admin/reports/programs` is identical except each row carries `program_id` instead of `course_id`.

### Row fields

| Field | Type | Meaning |
|---|---|---|
| `course_id` / `program_id` | uuid | Public id of the item. Use it to link to the detail screen or the per-item summary endpoints below |
| `name` | string | Name in the request locale (`Accept-Language`) |
| `trainer` | string \| null | Trainer's full name; `null` when no trainer is assigned |
| `total_enrolled` | integer | Enrolments (active + completed) |
| `completed` | integer | Enrolments with status `completed` |
| `completion_rate` | number | `completed / total_enrolled × 100`, 2 decimals; `0` when nobody is enrolled |
| `average_progress_percent` | number | Mean enrolment progress, 0–100, 2 decimals |
| `sessions_total` | integer | Number of class sessions the item has |
| `average_attendance_rate` | number | `attended rows / (sessions_total × total_enrolled) × 100`, 2 decimals; `0` when there are no sessions or no enrolments |
| `quiz_attempts_total` | integer | Completed quiz attempts across every quiz lesson in the item. **Courses only** — always `0` on the programs report (training programs have no quiz-taking mechanism yet) |
| `quiz_average_score` | number \| null | Mean `score` (0–100) across completed attempts, 2 decimals; `null` when `quiz_attempts_total` is `0` |
| `quiz_pass_rate` | number \| null | `% of completed attempts where passed = true`, 2 decimals; `null` when `quiz_attempts_total` is `0` |

Pagination fields are the standard Laravel paginator keys (`current_page`, `per_page`, `total`, `last_page`, `from`, `to`, `next_page_url`, `prev_page_url`, `links`). For a table, `data.data`, `data.total`, `data.per_page` and `data.current_page` are enough.

### Dated `period_metrics`/`trend`

```json
{
  "data": {
    "period": { "from": "2026-09-01", "to": "2026-09-03", "group_by": "day", "timezone": "UTC" },
    "data": [
      {
        "course_id": "00000000-0000-0000-0000-000000000001",
        "name": "Financial Management Essentials",
        "total_enrolled": 40,
        "completion_rate": 25,
        "period_metrics": { "new_enrollments": 2, "completions_in_period": 1, "cohort_completed": 1, "cohort_completion_rate": 50 },
        "trend": [
          { "period": "2026-09-01", "new_enrollments": 1, "completions_in_bucket": 0, "cohort_enrolled_to_date": 1, "cohort_completed_to_date": 0, "cohort_completion_rate": 0 },
          { "period": "2026-09-02", "new_enrollments": 1, "completions_in_bucket": 0, "cohort_enrolled_to_date": 2, "cohort_completed_to_date": 0, "cohort_completion_rate": 0 },
          { "period": "2026-09-03", "new_enrollments": 0, "completions_in_bucket": 1, "cohort_enrolled_to_date": 2, "cohort_completed_to_date": 1, "cohort_completion_rate": 50 }
        ]
      }
    ]
  }
}
```

This example omits the other lifetime row fields and the rest of the paginator for readability; every existing field above stays present and unchanged. Send any of `preset`/`from`/`to`/`group_by`, or `include_trend=1`, to get `data.period` plus each row's `period_metrics`; add `include_trend=1` to also get each row's `trend`.

| Field | Type | Meaning |
|---|---|---|
| `data.period` | object | The resolved range for the added fields — `from`/`to`, plus `group_by`/`timezone` |
| `period_metrics.new_enrollments` | integer | Enrolments whose `enrolled_at` falls in the resolved range — a **selected-period** count, distinct from the lifetime `total_enrolled` above |
| `period_metrics.completions_in_period` | integer | Enrolments whose `completed_at` falls in the range, including ones enrolled before `from` |
| `period_metrics.cohort_completed` | integer | Of the enrolments counted in `new_enrollments` (the "selected cohort"), how many have completed by `to` |
| `period_metrics.cohort_completion_rate` | number | `100 × cohort_completed / new_enrollments`, `0` for an empty cohort — a **selected-cohort** rate, distinct from the lifetime `completion_rate` above |
| `trend[]` | array | One entry per bucket, oldest first, zero-filled — `new_enrollments`/`completions_in_bucket` are per-bucket counts (sum to the `period_metrics` totals); `cohort_enrolled_to_date`/`cohort_completed_to_date`/`cohort_completion_rate` are cumulative as of that bucket's end (the last bucket's values equal `period_metrics`) |

A row with zero activity in the period still returns a full zero-filled `trend` (never an omitted key or another row's data), and a row's `trend` never changes when `search`/`page`/`per_page` change for the same period — it's computed strictly from that row's own data. `total_enrolled`, `completed`, `completion_rate`, attendance and quiz fields remain lifetime metrics throughout; there is no dated history for progress, attendance or quiz scores.

---

## Frontend notes

1. **Recorded courses have no attendance.** They own no class sessions, so they always return `sessions_total: 0` and `average_attendance_rate: 0`. Render `—` (or hide the column) when `sessions_total === 0` instead of showing a misleading `0%`.
2. **Zero enrolments** return `0` for every rate, never `null` or `NaN`. No divide-by-zero handling is needed client-side. The one exception is the quiz fields (see below), which use `null` rather than `0` for the two rate/score columns.
3. **Rates are numbers, not strings.** A whole value serialises as `25`, not `25.00` — format with your own decimal rules (e.g. `toFixed(1)`).
4. **Filters reset paging.** When `search`, `trainer_id` or `course_type` change, go back to `page=1`.
5. **Debounce `search`** (300 ms) — it runs a name lookup per request.
6. **Consistency with detail screens.** A row's rates equal the per-item summaries, so a drill-down will never disagree with the table:
   - Course: `GET /admin/courses/{course_id}/completion-summary` (`completion_rate`, `average_attendance_rate`, `average_progress_percent`)
   - Program: `GET /admin/programs/{program_id}/sessions/attendance-summary` (`completion_rate`, `average_attendance_rate`)
7. **Per-session detail** (who attended which session) is not part of these reports; use `GET /admin/programs/{program}/sessions/{session}/attendance` for programs.
8. **Permissions in the UI.** Hide the Courses / Programs tab when the admin lacks `reports.courses` / `reports.programs`. A missing permission returns `403` with an `X-Missing-Permission` header naming it.
9. **Programs never have quiz data.** `quiz_attempts_total` is always `0` and `quiz_average_score`/`quiz_pass_rate` are always `null` on `GET /admin/reports/programs` — training programs have no quiz-taking mechanism in this version of the platform. Hide the three quiz columns on the Programs tab, or render `—`.
10. **Draw each row's mini chart from that row's own `trend`, not from neighboring rows.** Request `include_trend=1` for the visible page; don't try to assemble a trend by comparing rows across pages or search results — each row's `trend` is independent of pagination/search and would just be flat/wrong if borrowed from elsewhere.
11. **Keep the lifetime and selected-period numbers visually distinct.** `total_enrolled`/`completion_rate` are lifetime; `period_metrics.new_enrollments`/`cohort_completion_rate` are for the selected range. Label them differently (e.g. "All time" vs. "This month") rather than swapping one for the other when a date filter is applied.

---

## Error codes

| HTTP Status | Trigger |
|---|---|
| `401 Unauthorized` | Missing or expired admin bearer token |
| `403 Forbidden` | Admin role lacks `reports.courses` / `reports.programs` |
| `422 Unprocessable Entity` | `to` before `from`; `from`/`to` not `Y-m-d`; `preset`/`group_by` outside their allowed values; explicit `group_by=day` with a resolved range over 366 days |

Unknown filter values are not rejected: an unmatched `trainer_id` or `search` simply returns an empty `data.data` with `total: 0`.

---

## Example requests

```bash
# First page of live courses for one trainer, 20 per page
curl -H "Authorization: Bearer {{admin_token}}" \
  "{{base_url}}/admin/reports/courses?course_type=live&trainer_id={{trainer_id}}&per_page=20"

# Programs whose name contains "leadership"
curl -H "Authorization: Bearer {{admin_token}}" \
  "{{base_url}}/admin/reports/programs?search=leadership"

# This month's courses with dated period_metrics/trend for the visible page
curl -H "Authorization: Bearer {{admin_token}}" \
  "{{base_url}}/admin/reports/courses?include_trend=1&preset=this_month&per_page=10"
```

Postman: see "Courses Report" and "Programs Report" in [admin.md](./admin.md#courses-report).

---

## Not included (yet)

- Certificates-issued and average-rating reports
- Sorting by metric
- CSV / Excel export
- Dated history for `average_progress_percent`, `average_attendance_rate`, `quiz_average_score`/`quiz_pass_rate` — these stay lifetime-only; only enrollment/completion have a `trend` (see above)

Revenue, most-subscribed items and book orders are covered separately — see [reports-revenue.md](./reports-revenue.md) (`GET /admin/reports/revenue`, `/revenue/transactions`, `/orders`).
