# 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