# 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