6.4 KiB
6.4 KiB
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 joinuser_roleswith the same datetime filter asrbac.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). CallsPOST /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
CoverAssignmentFormmodal) - Subjects: per-subject teacher assignment with inline Change
- Cohorts: cross-link to
/admin/cohorts/:idfor any cohort this class is in
client/src/pages/Classes.tsx:- New
View detailbutton 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.
- New
client/src/pages/Students.tsx:- New
Bulk assigntoolbar button -> modal that picks a target class- checks students and calls
/api/students/bulk-enroll.
- checks students and calls
BulkAssignStudentsModalcomponent added at the bottom of the file.
- New
client/src/App.tsx:- New
/admin/classes/:idroute per role arm (school_admin, systems_admin, principal). Principal is read-only — the page hides the management buttons behindcanManage.
- New
client/e2e/helpers/nav.ts:/admin/classes/1added toROUTES_PER_ROLE.school_adminso 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 onPUT /class-teacher; student gets 403 onbulk-enrollandtransfer
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'keepsrequest_status='approved'. The schema'senrollments.statusenum allowstransferred(existing 4-state lifecycle), butrequest_statusis 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=1uses 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_rolesreuse. Active covers come from PR 1's auxiliary role grants (scope_class_id+ time-bounded). TheGET /api/classes/:id/teachersendpoint 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'sCoverAssignmentFormmodal, 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
Classespage) - 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
- Backend smoke (curl): bulk-enroll, transfer, withAssignments, class-teacher swap, subject-teacher swap, teachers list — all OK
npm run db:initis unchanged (no migration)- New e2e spec: 3 admin tests + 3 RBAC tests pass
- Portal smoke (
@smoke) walks the new route without console errors - Existing cohort + rbac specs still pass
- Manual: admin can navigate
/classes->View detailon Form 1A -> swap form tutor -> add a student via Bulk assign -> see the roster update without a manual refresh