# 2026-07-22 — Phase 1 PR 2: student cohorts (frontend closeout)

**Branch:** `feature/admin-cohorts-2026-07-22`
**Owner:** fchin
**Scope:** finish the cohorts surface — frontend (list / detail / form), routes, nav, E2E

## What landed

This is the closeout commit for PR 2. The backend (controller, Knex
migration, store, list page, detail page) was already on the feature
branch from earlier work; this PR adds the missing frontend surface so
the feature is actually usable from the browser.

### Schema (recap — already on branch)

- New Knex migration `2026072200000020_cohorts.js`:
  - `student_cohorts` — programme (zimsec/igcse/as/a2/primary/other) + level + academic_year + description + date range + is_active
  - `cohort_students` — many-to-many to `users` (only students), with `status` (active/inactive/transferred/graduated)
  - `cohort_classes` — many-to-many to `classes`
  - `cohort_exam_groups` — many-to-many to `exam_groups` (so a cohort can pick the right exam paper set)
  - `enrollments.cohort_id` — nullable FK added via ALTER (backfill is a separate, non-blocking op)
  - All four tables follow the standard sync contract (uid, sync_status, last_synced_at, is_deleted)
  - Unique indexes on (cohort_id, student_id) and similar for the other two join tables

### Backend (recap — already on branch)

- `server/src/controllers/cohorts.controller.js`:
  - 10 endpoints: list, detail, create, update, soft-delete, bulk-add students, remove student, link class, unlink class, link exam group, unlink exam group
  - `requireAdmin` for writes; `canRead` (`school_admin | systems_admin | principal`) for reads
  - Every write is audit-logged via `AuditService`
- `server/src/index.js` — controller mounted at `/api/cohorts`
- `server/src/services/SyncEngine.js` — 4 new tables appended to `tablesToSync` after `user_roles`, in FK dependency order

### Frontend (this PR)

- `client/src/store/cohorts.ts` (recap — already on branch) — Zustand store with `Cohort`, `CohortDetail` types, full CRUD + linking, `SUPPORTED_PROGRAMMES` constant
- `client/src/pages/admin/Cohorts.tsx` (recap) — list with programme / academic-year / search filters; card grid view; admin-only "New Cohort" button
- `client/src/pages/admin/CohortDetail.tsx` (recap, with edit button) — Overview / Students / Classes / Exam Groups tabs; admin-only Add/Link modals; new Edit button (admin only) → `/admin/cohorts/:id/edit`
- `client/src/components/CohortForm.tsx` (new) — modal for create + edit
  - Programme dropdown drives `level` suggestions via `PROGRAMME_LEVELS` map (ZIMSEC → ECD-A through Form 6, IGCSE → Year 1-2/IGCSE, AS → Form 5, A2 → Form 6, primary → Reception-Year 6, other → free-text)
  - Fields: name, programme, level (datalist — pick or free-text), academic_year, description, start_date, end_date, is_active
  - Same modal shell pattern as `CoverAssignmentForm` and `RoleBadge` siblings
- `client/src/App.tsx`:
  - `Cohorts`, `CohortDetail`, `CohortForm` imports
  - `CohortFormPage` wrapper (reuses the form modal for both `/admin/cohorts/new` and `/admin/cohorts/:id/edit`)
  - 4 new routes per role arm — school_admin, systems_admin (all 4 routes) and principal (read-only — list + detail only)
- `client/src/components/Nav.tsx`:
  - `Cohorts` entry (GraduationCap icon) added to school_admin, systems_admin, principal
  - Principal sees the entry as read-only; the page hides the "New Cohort" button

### E2E

- `client/e2e/cohorts.spec.ts` (new):
  - Admin happy-path: list mounts; create flow lands the new cohort on the list
  - RBAC: teacher and student get 403 on `GET /api/cohorts`
  - Principal read-only: list mounts, no "New Cohort" button
  - Skips cleanly if the principal demo account is missing from the seed
- `client/e2e/helpers/nav.ts` — `/admin/cohorts` added to `ROUTES_PER_ROLE.school_admin` and `ROUTES_PER_ROLE.systems_admin` (so the existing portal smoke walks the route)

## Decisions / why

- **Programme-driven level suggestions, not enforcement.** Different schools
  use different labels. The datalist is a hint, not a constraint.
- **One form modal, two routes.** `/new` and `/:id/edit` both render the same
  `CohortForm`. The page is a thin router-owned wrapper that loads the
  cohort in edit mode. This keeps the create + edit UI in lockstep
  automatically.
- **Principal is read-only at the route level.** No `/new` or `/:id/edit`
  route is mounted for principal. Belt-and-braces: the page also hides
  management buttons behind `canManage`.
- **No auto-create-on-class.** Intentional — keep cohort creation explicit
  so admins think about programme + year. The link is one click on the
  Classes tab.
- **`other` programme has no level suggestions.** Forces free-text so admins
  don't accidentally pick a misleading label.

## Out of scope (deferred)

- Bulk transfer between cohorts (PR 3+ follow-up if requested)
- Per-cohort enrollment report (Phase 2 dashboard)
- Cross-cohort timetable / shared subjects view
- Auto-backfill of `enrollments.cohort_id` for existing rows (will run on
  the next academic rollover, or via the `/admin/academic-rollover` UI)

## Verification

- [x] `npm run db:init` runs cleanly on a fresh DB (cohorts migration is
      forward-only, applied via Knex)
- [x] Backend smoke (run pre-closeout): create / list / detail / add student
      / link class / link exam group / remove student / unlink class all OK
- [ ] Browser smoke: navigate to `/admin/cohorts`, create one, open detail,
      add a student, link a class, soft-delete
- [ ] Principal sees the list and the read-only detail
- [ ] Teacher is denied 403 on `GET /api/cohorts` and on `/admin/cohorts`
      (ProtectedRoute redirects to their own dashboard)
- [ ] `npm run test:e2e -- --grep "@flow|Cohorts"` passes the new spec
- [ ] Portal smoke (`@smoke`) walks the new route without console errors
