# 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<string, string[]> = {
  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.
