# Phase 1 — PR 2 closeout + PR 3 execution plan **Date:** 2026-07-27 **Branch:** `feature/admin-cohorts-2026-07-22` (from `dev`) **Companion:** [Phase 1 design](./2026-07-22-phase1-cohorts-assignments.md) > **Why this doc exists:** the design plan lays out *what* ships. This doc is the > ordered execution list for the closeout of PR 2 (cohorts frontend) and the > full build of PR 3 (class + teacher assignment UX). Read top-to-bottom, do > in order. ## Current state (re-verified 2026-07-27) PR 1 (auxiliary roles) is merged + pushed on the feature branch. PR 2 (cohorts) is **partially built** on the same branch — backend + Zustand store + list page + detail page are written, but routes/nav/E2E are missing. | Piece | State | |---|---| | Knex migration `2026072200000020_cohorts.js` | ✅ written, on disk | | `server/src/controllers/cohorts.controller.js` | ✅ written, on disk (10 endpoints, audit-logged) | | `server/src/index.js` mount at `/api/cohorts` | ✅ modified, uncommitted | | `server/src/services/SyncEngine.js` `tablesToSync` (4 new tables) | ✅ modified, uncommitted | | `client/src/store/cohorts.ts` (Zustand) | ✅ written, on disk | | `client/src/pages/admin/Cohorts.tsx` (list) | ✅ written, on disk | | `client/src/pages/admin/CohortDetail.tsx` (4 tabs + 3 modals) | ✅ written, on disk | | `client/src/components/CohortForm.tsx` (create/edit) | ❌ missing | | `client/src/App.tsx` routes (`/admin/cohorts`, `/admin/cohorts/:id`, `/admin/cohorts/new`) | ❌ missing | | `client/src/components/Nav.tsx` Cohorts entry | ❌ missing | | `client/e2e/helpers/nav.ts` ROUTES_PER_ROLE updates | ❌ missing | | `client/e2e/cohorts.spec.ts` smoke | ❌ missing | | `.harness/changelogs/2026-07-22-cohorts.md` | ❌ missing | | Commit + push | ❌ pending | **Backend smoke from prior session confirmed working:** create cohort, add student, link class, link exam group, list, detail, remove student, unlink class. ## PR 2 — Cohorts closeout ### A. `client/src/components/CohortForm.tsx` (new) A modal component that handles both create and edit. The plan's spec: - programme dropdown drives `level` suggestions - submit calls `createCohort` / `updateCohort` from store - close on success **Programme → level suggestions** (encoded as a constant, not magic strings): ```ts const PROGRAMME_LEVELS: Record = { zimsec: ['ECD A', 'ECD B', 'Grade 1', 'Grade 2', 'Grade 3', 'Grade 4', 'Grade 5', 'Grade 6', 'Grade 7', 'Form 1', 'Form 2', 'Form 3', 'Form 4', 'Form 5', 'Form 6'], igcse: ['Year 1', 'Year 2', 'IGCSE'], as: ['Form 5', 'AS Level'], a2: ['Form 6', 'A2 Level'], primary: ['Reception', 'Year 1', 'Year 2', 'Year 3', 'Year 4', 'Year 5', 'Year 6'], other: [], // free-text only }; ``` **UI shape** (mirrors `CoverAssignmentForm` + `UserRoles` patterns already in the repo): - ModalShell (fixed inset-0 overlay, inner `stopPropagation`) - Form fields: name (required), programme (required, dropdown), level (datalist of suggestions + free-text), academic_year (text), description (textarea), start_date / end_date (date inputs, optional), is_active (toggle) - Two action buttons: Cancel + "Save cohort" / "Create cohort" - Loading + error states; on success, `onClose()` — parent navigates away **Props:** ```ts interface CohortFormProps { cohortId?: number; // omit for create onClose: () => void; onSaved?: (cohort: Cohort) => void; } ``` **Why not edit-in-place on the detail page:** cards navigate to detail, and the detail page is read-mostly with tab actions. The "Edit cohort" action is a small button on the detail page header that opens this same modal. Simpler than two separate code paths. ### B. `client/src/App.tsx` — add cohort routes Three routes per role arm (school_admin, systems_admin, principal): - `/admin/cohorts` → `Cohorts` (list) - `/admin/cohorts/new` → a thin wrapper that renders `CohortForm` with no `cohortId` and on close navigates to `/admin/cohorts` (use a small inline component or pass a wrapper; the wrapper is ~10 lines) - `/admin/cohorts/:id` → `CohortDetail` Add an Edit button to the detail page header (admin only) that navigates to `/admin/cohorts/:id/edit` (same wrapper but with `cohortId=id`). **Add new page imports at the top of App.tsx:** ```ts import Cohorts from './pages/admin/Cohorts'; import CohortDetail from './pages/admin/CohortDetail'; import CohortForm from './components/CohortForm'; ``` Mount under the `case 'school_admin':`, `case 'systems_admin':`, and `case 'principal':` arms. Principal is read-only — gate the routes with the same `allowedRoles` array but the page checks `canManage` internally (it already does). ### C. `client/src/components/Nav.tsx` — Cohorts entries Add to all three role arms (school_admin, systems_admin, principal): ```ts { path: '/admin/cohorts', label: 'Cohorts', icon: GraduationCap }, ``` The `GraduationCap` icon is already imported. Place it near the existing `/students` entry for natural grouping. ### D. `client/e2e/helpers/nav.ts` — extend ROUTES_PER_ROLE Add `/admin/cohorts` to `school_admin` and `systems_admin`. Skip principal for now (no principal smoke spec yet — see `prinicpal: []` note in the file). ### E. `client/e2e/cohorts.spec.ts` (new) Smoke + happy-path: ```ts test.describe('Cohorts (Phase 1 PR 2) @flow', () => { test.beforeEach(async ({ page }) => { await loginAs(page, 'school_admin'); }); test('list page mounts and shows the empty or cohort state', async ({ page }) => { await page.goto(`${BASE}/admin/cohorts`); await expect(page.locator('h1, h2').first()).toBeVisible(); // Either a cohort card or the "No cohorts match" empty state const cardOrEmpty = page .getByText(/Form 4 IGCSE 2026|Grade 7 ZIMSEC|No cohorts/i) .or(page.getByRole('button', { name: /New Cohort/i })); await expect(cardOrEmpty.first()).toBeVisible({ timeout: 5_000 }); }); test('create new cohort end-to-end via the form', async ({ page }) => { await page.goto(`${BASE}/admin/cohorts/new`); const cohortName = `E2E ${Date.now()}`; await page.getByLabel(/name/i).fill(cohortName); await page.locator('select').first().selectOption('igcse'); await page.getByLabel(/level/i).fill('Year 1'); await page.getByLabel(/academic year/i).fill('2026'); await page.getByRole('button', { name: /Create cohort|Save cohort/i }).click(); // Should land back on list and show the new cohort await expect(page).toHaveURL(/\/admin\/cohorts$/); await expect(page.getByText(cohortName)).toBeVisible({ timeout: 5_000 }); }); test('principal can read cohort list but cannot see New Cohort', async ({ page }) => { // No principal demo seed; skip if not present const ok = await isRoleAvailable('principal'); test.skip(!ok, 'principal demo account not in seed'); await loginAs(page, 'principal'); await page.goto(`${BASE}/admin/cohorts`); await expect(page.locator('h1, h2').first()).toBeVisible(); await expect(page.getByRole('button', { name: /New Cohort/i })).toHaveCount(0); }); test('teacher gets 403 on the cohort list API', async ({ page }) => { await loginAs(page, 'teacher'); const res = await page.request.get(`${API}/cohorts`); expect(res.status()).toBe(403); }); }); ``` This file follows the same patterns as `client/e2e/rbac.spec.ts` (uses `loginAs`, `isRoleAvailable`, base URL constants). ### F. `.harness/changelogs/2026-07-22-cohorts.md` (new) Brief changelog per project convention (see `.harness/changelogs/2026-07-22-user-roles.md` for the template). ### G. Commit + push Single feature commit for PR 2: - Subject: `feat(cohorts): frontend + routes + e2e (PR 2 closeout)` - Body references the plan doc and lists the new files + key behaviors - Push to `origin/feature/admin-cohorts-2026-07-22` ## PR 3 — Class + teacher assignment UX ### Backend (additive, no schema changes) The plan calls for 5 new endpoints, all in existing controllers. Each one is small and audited. No new migration needed — `enrollments`, `classes`, and `subjects` already have the right columns (`class_teacher_id`, `teacher_id`, `status`, `request_status`). | Endpoint | Controller | Notes | |---|---|---| | `POST /api/students/bulk-enroll` | students.controller.js | `{ studentIds, classId, academicYear }` — bulk insert, `status='active', request_status='approved'`. Idempotent on existing rows. Wrapped in `db.transaction()`. Admin-only. | | `POST /api/enrollments/:id/transfer` | enrollments.controller.js | `{ newClassId }` — sets old to `transferred` (request_status), creates new `active`/`approved` row. Wrapped in `db.transaction()`. Admin/teacher. | | `PUT /api/classes/:id/class-teacher` | classes.controller.js | `{ teacherId }` — sets `classes.class_teacher_id`, validates teacher exists + role='teacher'. Admin-only. | | `GET /api/classes/:id/teachers` | classes.controller.js | Returns form tutor + per-subject teachers. Used by ClassDetail's Teachers tab. | | `POST /api/classes/:id/subjects/:subjectId/teacher` | classes.controller.js | `{ teacherId }` — sets `subjects.teacher_id`. Admin-only. | | `POST /api/classes?withAssignments=1` (create with assignments) | classes.controller.js | Accepts `{ name, section, capacity, classTeacherId, initialStudentIds }`. Wrapped in `db.transaction()`. Admin-only. | **Note on the transfer route:** the existing `enrollments.controller.js` already has a `/withdraw` endpoint at `POST /:id/withdraw` and a `/approve`, `/reject`. The plan's "withdraw" is already in place — no new work needed there. The new endpoints are just `transfer` and the four class-side ones. **E2E for backend:** covered by the frontend spec below (the happy-path tests exercise the real endpoints, not mocks). ### Frontend (5 new files + 2 modified) **New files:** - `client/src/pages/admin/ClassDetail.tsx` — 5 tabs (Overview / Roster / Teachers / Subjects / Cohorts). "Currently covering" indicator on Teachers tab uses the existing `GET /api/user-roles/active-covers` endpoint from PR 1. - `client/src/components/CreateClassWizard.tsx` — multi-step modal: 1) class details, 2) form tutor, 3) initial roster (optional). On submit, calls `POST /api/classes?withAssignments=1`. - `client/src/store/classDetail.ts` — Zustand slice for class detail (parallel to the cohorts pattern). **Modified files:** - `client/src/pages/Classes.tsx` — replace existing simple create form with `CreateClassWizard`. Add "View detail" button on each row → `/admin/classes/:id`. - `client/src/pages/Students.tsx` — add "Bulk assign" toolbar button → modal. Add enrollment status column. Pending-approval rows with Approve/Reject. - `client/src/components/CohortDetail.tsx` — on the Classes tab, link each class to `/admin/classes/:id` (cross-link to PR 3 surface). - `client/src/App.tsx` — add `/admin/classes/:id` route (admin + principal). - `client/src/components/Nav.tsx` — add `/admin/classes/:id` (only meaningful when filtered by id, so this is more of a deep-link target — the main nav entry stays `/classes`). - `client/e2e/helpers/nav.ts` — add `/admin/classes/1` to `school_admin`. ### File-order guidance (conventions: 5-file commit) PR 3 ships in two commits: 1. **Backend commit** — 5 controller changes + AuditService calls. Backend smoke test via curl. 2. **Frontend commit** — 3 new files + 3 modified files + App.tsx + Nav + nav.ts helper. **Why split:** easier to review the API contract before the UI. The backend contract is what the frontend signs against, and a coherent API surface is easier to land when reviewed in isolation. ### Out of scope for PR 3 (explicit) - **Auto-creating a cohort when a class is created.** Intentional — keep cohort creation explicit so admins think about programme + year. - **Cross-class bulk transfer.** Single-class transfer only; bulk transfer is a follow-up. - **Subject teacher UI rewrite.** The class detail's Subjects tab shows current teachers and lets admin change them — the underlying `subjects` table is the source of truth; no separate "subject catalog" page is in scope. - **Per-cohort enrollment report.** A read-only view of "students in cohort X enrolled in class Y" is a useful Phase 2 dashboard, not a PR 3 deliverable. ## Files to change (cumulative) ### PR 2 closeout - `client/src/components/CohortForm.tsx` (new) - `client/src/App.tsx` (modify — 3 routes × 3 role arms + 2 page imports + 1 component import) - `client/src/components/Nav.tsx` (modify — 3 entries) - `client/e2e/helpers/nav.ts` (modify — 2 entries) - `client/e2e/cohorts.spec.ts` (new) - `.harness/changelogs/2026-07-22-cohorts.md` (new) - Commit + push ### PR 3 - `server/src/controllers/students.controller.js` (modify — `POST /bulk-enroll`) - `server/src/controllers/enrollments.controller.js` (modify — `POST /:id/transfer`) - `server/src/controllers/classes.controller.js` (modify — 3 new routes) - `client/src/pages/admin/ClassDetail.tsx` (new) - `client/src/components/CreateClassWizard.tsx` (new) - `client/src/store/classDetail.ts` (new) - `client/src/pages/Classes.tsx` (modify) - `client/src/pages/Students.tsx` (modify) - `client/src/pages/admin/CohortDetail.tsx` (modify — cross-link to /admin/classes/:id) - `client/src/App.tsx` (modify) - `client/src/components/Nav.tsx` (modify) - `client/e2e/helpers/nav.ts` (modify) - `client/e2e/class-assignments.spec.ts` (new) - `.harness/changelogs/2026-07-22-class-assignments.md` (new) - Commit + push (×2 — backend, then frontend) ## Verification - [ ] `npm run db:init` runs cleanly on a fresh DB (idempotent) - [ ] Backend smoke: create class, assign form tutor, bulk-enroll 3 students - [ ] Backend smoke: transfer student (old → transferred, new → active) - [ ] Backend smoke: link subject teacher - [ ] Frontend smoke (admin): navigate to `/admin/classes/1`, see all 5 tabs, switch a form tutor, see it update - [ ] Frontend smoke (admin): bulk-assign 2 unassigned students - [ ] Frontend smoke (principal): read-only on ClassDetail (no Edit / Change buttons; can see Roster + Teachers + Cohorts) - [ ] Frontend smoke (teacher): `GET /api/classes/1/teachers` works for a class they teach, gets 403 for a class they don't - [ ] Portal smoke (`@smoke`) for all three role arms walks the new routes without errors - [ ] Cohorts smoke (`@flow`) from PR 2 still passes - [ ] `git log --oneline origin/feature/admin-cohorts-2026-07-22` shows the three new commits (cohorts closeout, PR 3 backend, PR 3 frontend) on top of the merge commit ## Order of execution 1. PR 2 closeout, top to bottom (A → G). Commit + push. 2. PR 3 backend (4 controller changes, all in one commit). Smoke via curl. 3. PR 3 frontend (top to bottom). Commit + push. 4. Run full Playwright suite (portal smokes + cohorts flow + class-assignments flow) and confirm green. **Why this order:** PR 2's frontend is the smaller lift and is half-done already; shipping it unblocks the cross-link from CohortDetail to ClassDetail (PR 3). PR 3's backend is small and isolated — getting the API surface locked first lets the frontend sign against a known contract.