# 2026-07-22 — Phase 1 PR 3: class + teacher assignment UX

**Branch:** `feature/admin-cohorts-2026-07-22`
**Owner:** fchin
**Scope:** finish the class / teacher / roster assignment workflow in one consistent surface

## What landed

Two commits: backend first (5 controller changes, no schema), then frontend
(3 new files, 2 modified files, 1 route + nav entry, 1 e2e spec, 1 changelog).

### Backend (commit 1)

- `server/src/controllers/students.controller.js`:
  - `POST /api/students/bulk-enroll` — `{ studentIds, classId, academicYear? }`
    Returns `{ added, skipped, errors }`. Idempotent on (student_id, class_id)
    so re-running the same batch is a no-op. Admin/teacher only.
- `server/src/controllers/enrollments.controller.js`:
  - `POST /api/enrollments/:id/transfer` — `{ newClassId, reason? }`
    Atomic: old row -> `status='transferred'`, new row -> active/approved.
    Refuses if the student is already active in the destination class.
    Admin/teacher only. Audit-logged.
- `server/src/controllers/classes.controller.js`:
  - `POST /api/classes?withAssignments=1` — accepts
    `{ classTeacherId, initialStudentIds, academicYear? }`. Validates the
    teacher (role=teacher) and each student id (exists + role=student) up
    front, then runs the insert + bulk-enrol in one transaction.
  - `PUT /api/classes/:id/class-teacher` — `{ teacherId }` (null clears).
    Admin-only. Audits the swap.
  - `GET /api/classes/:id/teachers` — returns
    `{ class_teacher, subject_teachers, active_covers }` in one
    round-trip. Active covers join `user_roles` with the same datetime
    filter as `rbac.getActiveCovers` (PR 1's helper, inlined here to keep
    the controller self-contained).
  - `POST /api/classes/:id/subjects/:subjectId/teacher` — `{ teacherId }`
    (null clears). Refuses if the subject isn't bound to the path class.
    Admin-only. Audits the swap.

### Frontend (commit 2)

- `client/src/store/classDetail.ts` — new Zustand store. Composes the
  class detail from the four existing endpoints (class detail, teachers,
  enrollments, cohorts-of-class). Exposes write actions: `setFormTeacher`,
  `setSubjectTeacher`, `bulkEnrol`, `transferStudent`, `withdrawStudent`.
- `client/src/components/CreateClassWizard.tsx` — new modal. One flow:
  pick the class details, assign a form tutor (optional), bulk-enrol an
  initial roster (optional). Calls `POST /api/classes?withAssignments=1`.
- `client/src/pages/admin/ClassDetail.tsx` — new page, 5 tabs:
  - Overview: stat cards (students, form tutor, subjects, cohorts) + description
  - Roster: active students + Bulk assign + per-row Transfer / Withdraw
  - Teachers: form tutor (with inline Change) + active covers + Add cover
    (uses PR 1's `CoverAssignmentForm` modal)
  - Subjects: per-subject teacher assignment with inline Change
  - Cohorts: cross-link to `/admin/cohorts/:id` for any cohort this class is in
- `client/src/pages/Classes.tsx`:
  - New `View detail` button on each class card -> `/admin/classes/:id`
  - The old simple create form is replaced with `CreateClassWizard`. On
    create, the wizard closes, the list reloads, and the admin jumps
    straight to the new class detail.
- `client/src/pages/Students.tsx`:
  - New `Bulk assign` toolbar button -> modal that picks a target class
    + checks students and calls `/api/students/bulk-enroll`.
  - `BulkAssignStudentsModal` component added at the bottom of the file.
- `client/src/App.tsx`:
  - New `/admin/classes/:id` route per role arm (school_admin,
    systems_admin, principal). Principal is read-only — the page hides
    the management buttons behind `canManage`.
- `client/e2e/helpers/nav.ts`:
  - `/admin/classes/1` added to `ROUTES_PER_ROLE.school_admin` so the
    existing portal smoke walks the new route.
- `client/e2e/class-assignments.spec.ts` — new spec:
  - Admin: page mounts, 5 tabs visible
  - Admin API: swap form tutor, bulk-enrol (idempotent)
  - RBAC: teacher can read `/api/classes/:id` (200) but gets 403 on
    `PUT /class-teacher`; student gets 403 on `bulk-enroll` and `transfer`

## Decisions / why

- **5 tabs, not 5 pages.** A single URL with tab state keeps the
  per-class data fetch to one call. The store composes the four
  endpoints once, the tabs re-render from store state.
- **`status='transferred'` keeps `request_status='approved'`.** The
  schema's `enrollments.status` enum allows `transferred` (existing
  4-state lifecycle), but `request_status` is a 4-value enum
  (`pending`/`approved`/`rejected`/`withdrawn`). Transfer keeps the
  approval state to satisfy the constraint while flipping the lifecycle
  field. Documented in the controller's transfer handler.
- **`POST /api/classes?withAssignments=1`** uses a query flag rather
  than a body field so the no-assignments call shape is unchanged. The
  new flag is opt-in.
- **No auto-cohort-on-class-creation.** Intentional — keep cohort
  creation explicit so admins think about programme + year. The Cohorts
  tab on the class detail page is a read view; the link is a manual
  one-click.
- **PR 1's `user_roles` reuse.** Active covers come from PR 1's
  auxiliary role grants (`scope_class_id` + time-bounded). The
  `GET /api/classes/:id/teachers` endpoint inlines the same datetime
  filter so it doesn't depend on a server-side RBAC helper. The
  ClassDetail page renders the "Add cover" button using PR 1's
  `CoverAssignmentForm` modal, so a teacher covering a class for two
  weeks stays a one-screen flow.

## Out of scope (deferred)

- Per-class attendance / assessment drill-downs (already exist as
  separate routes; PR 3 just adds the cross-link surface)
- Subject-level "Add new subject" UI inside ClassDetail (still lives on
  the existing `Classes` page)
- Auto-promotion of a student to next-year class (academic rollover is
  a separate feature under `/admin/academic-rollover`)
- Bulk transfer between cohorts (one student at a time this PR)

## Verification

- [x] Backend smoke (curl): bulk-enroll, transfer, withAssignments,
      class-teacher swap, subject-teacher swap, teachers list — all OK
- [x] `npm run db:init` is unchanged (no migration)
- [x] New e2e spec: 3 admin tests + 3 RBAC tests pass
- [x] Portal smoke (`@smoke`) walks the new route without console errors
- [x] Existing cohort + rbac specs still pass
- [x] Manual: admin can navigate `/classes` -> `View detail` on Form 1A
      -> swap form tutor -> add a student via Bulk assign -> see the
      roster update without a manual refresh
