# Program Onsite Attendance — Trainer Frontend Guide

How the trainer dashboard shows an **onsite session's student roster with each student's attendance status**, and how to save changes back. It covers the new roster endpoint (`GET …/sessions/{session}/attendance`) and how it pairs with the existing mark-attendance endpoint (`POST …/sessions/{session}/attendance`).

- **Audience:** frontend developers (trainer dashboard)
- **Base path:** `{{base_url}}` = `/api/v1`
- **Auth:** `Authorization: Bearer {{trainer_token}}` (user type `trainer`)
- **Related docs:** [training-programs-trainer.md](./training-programs-trainer.md) (full trainer program reference), [permissions-guide.md](./permissions-guide.md) (trainer capabilities), [trainer.md](./trainer.md) (auto-generated endpoint reference)

Common headers on every call: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`, `Authorization: Bearer {{trainer_token}}`, and `X-Country: EG|SA|AE` (see §6). Responses use the standard envelope `{ "status", "message", "data" }`.

---

## 1. Concepts

- **Onsite sessions only.** A program session has a `mode` of `online` or `onsite`. Online attendance is self-service (the student joins and is marked automatically). Onsite attendance is recorded by the **trainer**, who sees the class roster, ticks who showed up and saves. These endpoints reject `online` sessions with a `422`.
- **The roster is the enrolled students, not the attendance rows.** The roster endpoint returns *every* student enrolled in the program. Someone the trainer hasn't marked yet is still in the list, with `attended: false` and `marked_by: null`. You never have to merge two lists.
- **`id` of a student is the user's UUID.** The `id` in each roster row is exactly what you send back in `student_ids` when marking attendance.
- **Permission:** both the roster and the mark endpoint require the trainer capability **`manual_attendance`**. Admin grants it per trainer; it's included in the `permissions` array returned by login and `GET /trainer/profile`.
- **Ownership:** the program must be assigned to the calling trainer. Another trainer's program returns `404`, identical to a program that doesn't exist.

---

## 2. Endpoint map

| Step | Method & path | Purpose |
|---|---|---|
| 1 | `GET /trainer/programs/{program}/sessions` | List the program's sessions — gives you each session's `id` and `mode` |
| 2 | **`GET /trainer/programs/{program}/sessions/{session}/attendance`** | **New.** Roster: enrolled students + attendance status |
| 3 | `POST /trainer/programs/{program}/sessions/{session}/attendance` | Save attendance for a batch of students (existing) |

`{program}` and `{session}` are **UUIDs** (the `id` field of the program/session resources).

---

## 3. Getting the session id

The roster endpoint takes the session in the URL. Take it from the sessions list (`GET /trainer/programs/{program}/sessions`, `data[].id`) or from `sessions[]` in the program detail response. Only show the "Attendance" action on rows where `mode === "onsite"`:

```json
{
  "id": "9f0c1a52-6d3e-4c58-9e0b-2a1d7f3b8c11",
  "mode": "onsite",
  "session_date": "2026-11-02",
  "start_time": "14:00",
  "duration_minutes": 60,
  "location": "Cairo — Hall B",
  "is_live": false,
  "can_join": false,
  "attended_count": 12,
  "sort_order": 1
}
```

`attended_count` is the number of students currently marked present; use it for the badge on the schedule tab.

---

## 4. `GET /trainer/programs/{program}/sessions/{session}/attendance`

**Path variables:** `program` (UUID), `session` (UUID). No query string, no body. **Not paginated** — the whole class comes back in one response, sorted by student name (A→Z).

### Request

```http
GET /api/v1/trainer/programs/3b6e…-uuid/sessions/9f0c1a52-6d3e-4c58-9e0b-2a1d7f3b8c11/attendance
Authorization: Bearer {{trainer_token}}
Accept: application/json
Accept-Language: en
```

### Response — `200 OK`

```json
{
  "status": "success",
  "message": "Attendance retrieved successfully",
  "data": [
    {
      "id": "a1f2…-uuid",
      "name": "Alpha Present",
      "phone": "1001234567",
      "attended": true,
      "joined_at": "2026-11-02 14:05:00",
      "marked_by": "trainer"
    },
    {
      "id": "b7c9…-uuid",
      "name": "Bravo Absent",
      "phone": "1007654321",
      "attended": false,
      "joined_at": null,
      "marked_by": "trainer"
    },
    {
      "id": "c3d4…-uuid",
      "name": "Charlie Unmarked",
      "phone": "1002223333",
      "attended": false,
      "joined_at": null,
      "marked_by": null
    }
  ]
}
```

### Row fields

| Field | Type | Meaning |
|---|---|---|
| `id` | string (UUID) | Student's user UUID. Send this in `student_ids` when marking. |
| `name` | string \| null | Student's full name |
| `phone` | string \| null | Student's phone, for display |
| `attended` | boolean | `true` = marked present. `false` covers *both* "marked absent" and "never marked". |
| `joined_at` | string \| null | `YYYY-MM-DD HH:mm:ss` when marked present, otherwise `null` |
| `marked_by` | string \| null | `"trainer"` once someone has recorded a status for this student, `null` if nobody has |

> To tell "explicitly marked absent" from "not marked yet", use `attended === false && marked_by === null` → *not marked yet*. For most UIs both are simply an unchecked row and you don't need to distinguish them.

An empty program returns `"data": []` — show an "No students enrolled yet" empty state.

### Errors

| HTTP | When | Body |
|---|---|---|
| 401 | Missing/expired token | standard auth error — redirect to login |
| 403 | Trainer lacks `manual_attendance` | `{"status":"error","message":"You are not permitted to record attendance manually.","data":{"error_code":"PERMISSION_DENIED"}}` |
| 404 | Program or session not found, **or** the program belongs to another trainer, **or** the session belongs to a different program | `{"status":"fail","message":"Data not found","data":null}` |
| 422 | Session is not `onsite` | `{"status":"fail","message":"Only onsite sessions have trainer-marked attendance — online sessions are self-service via join.","data":null}` |

The roster can be opened **before** the session starts (so the trainer can preview who's enrolled) — the "session has started" rule applies only when *saving* (see §5).

---

## 5. Saving attendance — `POST …/sessions/{session}/attendance` (existing)

```http
POST /api/v1/trainer/programs/{program}/sessions/{session}/attendance
Content-Type: application/json

{ "student_ids": ["a1f2…-uuid", "c3d4…-uuid"], "attended": true }
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `student_ids` | array of UUID | yes | At least 1. Use the roster's `id` values. |
| `attended` | boolean | no | Defaults to `true`. Send `false` to **un-mark** students. |

**One call applies one `attended` value to every listed student.** There is no per-student value in a single request. So a screen that lets the trainer tick *and* untick students needs up to two requests (see §7).

**200 OK** — returns only the students you sent (not the full roster):

```json
{
  "status": "success",
  "message": "Attendance recorded successfully",
  "data": [
    {
      "session_id": "9f0c…-uuid",
      "student": { "id": "a1f2…-uuid", "name": "Alpha Present" },
      "attended": true,
      "joined_at": "2026-11-02 14:05:00",
      "marked_by": "trainer"
    }
  ]
}
```

> The shapes differ slightly: the roster rows are **flat** (`id`, `name`), the save response nests the student (`student.id`, `student.name`). The student UUID is the same value in both — match on it.

### Save errors

| HTTP | When | Message |
|---|---|---|
| 403 | Missing `manual_attendance` | `You are not permitted to record attendance manually.` |
| 404 | Program/session not found or not yours | `Data not found` |
| 422 | Session is `online` | `Only onsite sessions have trainer-marked attendance — online sessions are self-service via join.` |
| 422 | **Session hasn't started yet** | `Attendance can only be recorded once the session has started.` |
| 422 | Any listed student isn't enrolled | `One or more students are not enrolled in this program.` — **all-or-nothing**: nothing is saved |
| 422 | Validation (`student_ids` missing/empty, not UUIDs, unknown user) | Standard validation envelope with per-field `errors` |

Saving a student as present may silently issue the program certificate if the program has an attendance threshold configured. Nothing in the response announces it.

---

## 6. Timezone and "has the session started?"

The server decides whether a session has started using the session's `session_date` + `start_time` interpreted in the timezone of the request's **`X-Country`** header: `EG → Africa/Cairo`, `SA → Asia/Riyadh`, `AE → Asia/Dubai` (default `EG` when the header is absent). Keep sending the same `X-Country` header you use for the rest of the app so this matches what the trainer sees on the schedule.

Two ways to handle the start rule in the UI:

1. **Recommended:** compute `started = now >= session_date + start_time` (in that timezone) and **disable the Save button with a hint** ("Attendance opens when the session starts") before it starts. The roster itself can still be shown.
2. Or just let the user try and surface the `422` message in a toast. Either way, treat the server as the source of truth.

---

## 7. Suggested integration flow

```
Schedule tab                      Attendance screen
─────────────                     ─────────────────
GET /programs/{p}/sessions   →    click "Attendance" on an onsite row
                                       │
                                       ▼
                                  GET /programs/{p}/sessions/{s}/attendance
                                       │  keep `original` = response.data
                                       ▼
                                  render checkbox per student (checked = attended)
                                       │  trainer ticks / unticks
                                       ▼
                                  on Save: diff against `original`
                                       ├─ newly ticked   → POST { student_ids, attended: true }
                                       └─ newly unticked → POST { student_ids, attended: false }
                                       ▼
                                  re-fetch the roster (or merge responses by student id)
```

### Diffing on save

```ts
type RosterRow = {
  id: string; name: string | null; phone: string | null;
  attended: boolean; joined_at: string | null; marked_by: 'trainer' | null;
};

const base = `/trainer/programs/${programId}/sessions/${sessionId}/attendance`;

async function loadRoster(): Promise<RosterRow[]> {
  const { data } = await api.get(base);
  return data.data;
}

async function saveAttendance(original: RosterRow[], checkedIds: Set<string>) {
  const toPresent = original.filter(r => !r.attended && checkedIds.has(r.id)).map(r => r.id);
  const toAbsent  = original.filter(r =>  r.attended && !checkedIds.has(r.id)).map(r => r.id);

  // Only call what changed; never send an empty student_ids (422).
  if (toPresent.length) await api.post(base, { student_ids: toPresent, attended: true });
  if (toAbsent.length)  await api.post(base, { student_ids: toAbsent,  attended: false });

  return loadRoster(); // authoritative state after saving
}
```

Notes:

- **Don't send already-correct students.** A student who is unchecked and was never marked is already `attended: false`; no request is needed to "mark absent".
- **Two requests aren't atomic together.** Each request is all-or-nothing on its own, but if the first succeeds and the second fails the roster is partially updated. Re-fetch the roster after any failure so the screen reflects reality.
- **"Select all" / "Mark all present"** is just `student_ids = roster.filter(r => !r.attended).map(r => r.id)` with `attended: true`.
- Re-fetching after save is the simplest way to refresh `joined_at` / `marked_by` and the `attended_count` badge on the schedule tab (re-call `GET …/sessions` when returning to it).

---

## 8. UI state checklist

| State | What to show |
|---|---|
| Loading | Skeleton rows |
| `data: []` | "No students enrolled in this program yet." |
| Session `mode !== "onsite"` | Hide the Attendance action; if reached directly the `422` message is shown |
| Trainer lacks `manual_attendance` | Hide the Attendance action (`user.permissions.includes('manual_attendance')`); still handle `403` |
| Session not started | Roster visible, Save disabled with a hint (§6) |
| `404` | "Session not found" and navigate back to the program |
| Save `422` (not enrolled) | Show the message and re-fetch the roster (enrollment changed under you) |
| Save success | Toast `Attendance recorded successfully`, refresh roster |

---

## 9. Quick reference

| | Roster | Save |
|---|---|---|
| Method & path | `GET /trainer/programs/{program}/sessions/{session}/attendance` | `POST /trainer/programs/{program}/sessions/{session}/attendance` |
| Permission | `manual_attendance` | `manual_attendance` |
| Body | none | `{ student_ids: uuid[], attended?: boolean }` |
| Pagination | none (whole class) | n/a |
| Needs session started | No | **Yes** |
| Onsite only | Yes (`422` otherwise) | Yes (`422` otherwise) |
| Student shape | flat `id`, `name`, `phone`, `attended`, `joined_at`, `marked_by` | nested `student{id,name}`, `session_id`, `attended`, `joined_at`, `marked_by` |
| Route name | `trainer.programs.sessions.attendance.index` | `trainer.programs.sessions.attendance` |
| Source | `TrainingProgramService::sessionAttendance()` | `TrainingProgramService::markAttendance()` |
