124 lines
6.4 KiB
Markdown
124 lines
6.4 KiB
Markdown
# 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
|