- Given Settings → Khoá học or Classes → Khoá học
- When I create a course with name, level, age band, number of lessons, session duration, default capacity, price and pricing method
- Then it is saved through the existing course endpoint plus the extension fields, and appears in the class form.
PRD 03 · Classes and scheduling
Courses, classes, rooms, recurring schedules that generate physical sessions, timetable views, capacity, enrollments, teacher and substitute assignment, and make-up sessions. Extends the existing Class, ClassSchedule and Course objects with rooms and materialised sessions.
Key facts, origin and limits
Drafted by an AI assistant in a single session on 10/09/2026, from the original request quoted verbatim on the hub page; the Proposed design section was added on 16/09/2026. A proposal for team discussion, not a decision of the product team.
Sources
- Public competitor research (PSE ONE, pse4.one), the
center-crm-frontendcodebase and the staging/v3OpenAPI specification (182 paths), as of 10/09/2026. - The existing / extend / new tags on this page come from a comparison with the staging
/v3specification at that date.
Limits
- Verification pending in-app survey, because automated login to the reference product was not authorised in the drafting session:
- Competitor's "lịch học tự động" inputs: whether sessions are generated as rows and how holidays are handled.
- Whether rooms exist and conflicts are checked.
- How "phân lớp giáo viên" and "xếp lịch giáo viên theo tuần" look (grid vs list).
- Their handling of make-up ("học bù") and whether it consumes a package session.
- Class capacity and waiting list behaviour.
Proposed design
The module revolves around three screens: the class list, the class detail with sessions materialised as rows, and the room timetable; the most important thing to see is that every room or teacher conflict is detected and shown in place before anything is saved.









1Purpose
A center lives by its timetable. Classes must be created quickly from a course template, placed in rooms without collisions, taught by an assigned teacher with a substitute mechanism, and produce a list of concrete sessions that attendance, tuition packages and teacher pay can hang off. The FLYER backend has the class (name, code, type), a recurrence template (ClassSchedule with days_of_week, time_local, start and end date) used to auto-assign FLYER lessons, and a course catalog with pricing. This PRD adds what a physical center needs: rooms, sessions as rows, capacity, enrollments with dates, make-ups and timetable views.
In scope
- Course catalog (level, lessons, duration, price link to PRD 05).
- Class creation with schedule pattern, room, teacher, capacity, trial seats.
- Session generation, editing single sessions, cancellations, holidays.
- Timetable views by branch, room, teacher, class; conflict detection.
- Enrollments (student in class with dates), roster, waiting list, class transfer.
- Make-up sessions and one-off sessions.
Out of scope
- Automatic timetable optimisation.
- Online class links and video conferencing (existing FLYER feature "Dạy trực tuyến" may link later).
- Curriculum content; FLYER program assignment stays in ClassSchedule as today.
2What exists today
| Object | Fields | Gap |
|---|---|---|
Class / SchoolClass (GET /v3/teacher/classes) | id int64, name, code, class_type, student_count, courses[{id, name, price}], created_at | no level, room, capacity, dates, teacher, status |
ClassSchedule (POST/GET/PUT/DELETE /v3/teacher/class-schedules) | class_id, days_of_week[], time_local, timezone, start_date, end_date, is_active, program_type STUDY|BOOK, program ids, delivery_time, deadline_days, lessons[] (rollup with remind status) | one active schedule per class; no duration, room, teacher; lessons are content units, not physical sessions |
Course (/v3/teacher/school-finance/course/courses) | name, description, level, number_of_lessons, price, currency, pricing_method ONE_TIME|MONTHLY, split_payment_count, status | no session duration, no age band, no default capacity |
POST .../classes/{class_id}/courses | attach course to class | keep |
POST /v3/teacher/class-schedules/{id}/remind, preview-remind-time | FLYER lesson reminders | keep |
Exam sessions (SessionInput with check_point_id) | exam-only | not reused for class sessions |
3Personas and roles
| Persona | Needs |
|---|---|
| Academic admin | Create classes, generate and adjust sessions, fill classes, handle transfers and make-ups, print timetables. |
| Branch manager | See fill rate and teacher load, approve class opening and closing. |
| Teacher | Own weekly timetable, roster with notes, request substitute. |
| Admissions | Find classes with seats by level and time for trials and conversion. |
| Guardian | See the child's timetable and changes (PRD 07). |
4User stories and acceptance criteria
- Given a course
- When I enter class name (auto-suggested "Movers 2 · T3/T5 18:00"), branch, course, level, teacher, assistant (optional), room, capacity, trial seats, start date, end date or number of sessions, weekday pattern with time per weekday, and holidays to skip
- Then the class is created (existing endpoint) with the extension record, a ClassSchedule is created for FLYER lessons if a program is chosen, and N sessions are generated as rows with date, start, end, room, teacher; the form shows conflicts before saving.
- Given a session being created or moved
- When another session overlaps in the same room, or with the same teacher, in the same branch (teacher conflicts checked across branches)
- Then the API returns 409
CONFLICTwith the conflicting sessions and the UI shows them inline with an "Vẫn lưu" override forclasses.override_conflict.
- Given a generated session
- When I change its date, time, room or teacher
- Then only that session changes and is marked "Đã điều chỉnh"; choosing "Áp dụng cho các buổi sau" regenerates future planned sessions (held sessions untouched); guardians of enrolled students receive a change notice (PRD 07) if enabled.
- Given a planned session
- When I cancel with reason and choose "Thêm buổi bù cuối khoá" or "Không bù"
- Then status is
cancelled(orcancelled_latewhen within 24 h, relevant to PRD 06 pay); a make-up session is appended after the last session if chosen; no session is consumed from student packages; guardians are notified.
- Given a session
- When I set substitute teacher
- Then the session shows both main and substitute; the substitute sees it in their timetable and may mark attendance; PRD 06 pays the substitute.
- Given a class with capacity 12 and 11 active enrollments
- When I add a student with start date
- Then the enrollment is created; at 12 the class shows "Đầy" and further adds go to the waiting list; transfer to another class ends the enrollment at date D and starts a new one at D (same day allowed); ending an enrollment before the class end asks for reason (PRD 02 lifecycle).
- Given an absence with excuse (PRD 04) and tenant policy "make-up allowed"
- When I pick a session of another class at the same level with free seats, or create a one-off make-up session
- Then a
makeup_bookingis created; the student appears on that session roster with a Học bù chip; attending it does not consume a package session if the tenant policy says so.
- Given the timetable page
- When I switch between By room, By teacher, By class, and a date range
- Then a week grid (07:00 to 22:00, 30-minute rows) shows session blocks with class name, teacher, room and enrolled/capacity; clicking opens the session drawer; print produces an A4 landscape sheet per room.
- Given the last session is held or the admin chooses Kết thúc lớp
- When confirmed
- Then class status becomes
completed, enrollments end, students with no other enrollment become Completed (PRD 02), and the class list moves it to Đã kết thúc.
5Screens
Tabs: Đang học · Sắp khai giảng · Đầy · Đã kết thúc · Tất cả. Filters: branch, course, level, teacher, weekday, time band (sáng/chiều/tối), has seats. Buttons: + Tạo lớp, Thời khoá biểu.
| Column | Notes |
|---|---|
| Mã lớp | class.code |
| Tên lớp | link; level chip |
| Lịch | "T3, T5 · 18:00–19:30" |
| Phòng | room name |
| Giáo viên | avatar + name; assistant small |
| Sĩ số | "11/12" with bar; red when full; waiting count |
| Tiến độ | held / total sessions |
| Khai giảng · Kết thúc | dates |
| Trạng thái | Sắp mở (exc) · Đang học (ok) · Đầy (late) · Đã kết thúc (grey) |
| Thao tác | Xem lịch · Thêm học viên · Điểm danh hôm nay |
Empty state: "Chưa có lớp. Tạo khoá học trước, rồi mở lớp từ khoá học."
Header per Cloud theme lop-hoc frame: name, subtitle "T3 & T5, 18:00 · 12 học viên · Movers", buttons Sửa · Giao bài (FLYER) · kebab (Nhân bản lớp, Kết thúc lớp). Tabs: Tổng quan · Học viên · Buổi học · Điểm danh · Bài tập (existing FLYER assign tests) · Học phí · Cài đặt. Right rail: Thông tin lớp (code with copy, teacher, room, capacity, course, program), fill rate, next session.
- Học viên: roster table (name, guardian phone, start date, status, sessions attended/total, balance, flags) with actions Chuyển lớp, Kết thúc, Gửi tin; waiting list section; + Thêm học viên (search students of branch, shows level match).
- Buổi học: sessions table (No., date, time, room, teacher, substitute, status, attendance marked, note) with per-row edit, cancel, substitute; toolbar Tạo buổi, Tạo lại lịch, Nghỉ lễ.
- Cài đặt: capacity, trial seats, allow make-up, FLYER program (existing ClassSchedule fields).
| Field | Type | Validation |
|---|---|---|
| Chi nhánh | select | required |
| Khoá học | select | required; fills level, duration, capacity, lessons |
| Tên lớp | text | required; unique per branch among open classes |
| Mã lớp | text | auto <LEVEL>-<YYMM>-##, editable |
| Giáo viên chính, Trợ giảng | staff picker (teaching, branch) | main required before first session |
| Phòng | select rooms of branch | required |
| Sĩ số tối đa, Chỗ học thử | int | 1–60; trial ≤ 5 |
| Lịch học | weekday chips; per weekday time from/to; duration default from course | ≥ 1 weekday; from < to |
| Khai giảng | date | required |
| Kết thúc | radio: theo số buổi (default course lessons) / theo ngày | |
| Ngày nghỉ | multi-date; preloaded from tenant holiday list | |
| Chương trình FLYER | existing program picker (optional) |
Step 2 preview: generated sessions list with conflict chips; button Tạo lớp và N buổi.
Week grid, columns = rooms (or teachers, or classes), rows = 30 min. Block colour by level. Drag to move (desktop) with conflict check on drop. Filters: branch, level. Buttons: In, Xuất iCal (teacher view). Day view for mobile.
Table: name, branch, capacity, equipment tags, active. Form: name (unique per branch), capacity, note.
6Business rules
- Sessions are generated from the pattern at class creation and whenever the pattern changes, only for sessions with status
planned. Held or cancelled sessions are never regenerated. - Session numbering is by date order among non-cancelled sessions; renumbered on regeneration.
- Room conflict: same room, overlapping time, both sessions not cancelled. Teacher conflict: same staff as main or substitute, overlapping time, any branch. Buffer between sessions is 0 by default (tenant setting up to 30 min).
- Capacity counts enrollments active on the session date plus trial and make-up bookings for that session; trial seats are reserved separately.
- An enrollment has start and end dates; a student is on the roster of sessions within [start, end].
- Class status:
draft(no sessions),scheduled(start date in future),active,completed,cancelled; derived from dates and sessions except cancelled. - Late cancellation threshold 24 h (tenant setting) marks
cancelled_latefor pay rules. - Level mapping for placement suggestion: tenant table level_code → score band; default seeded from CEFR (Pre-A1, A1, A2, B1, B2, C1) and Cambridge YLE (Starters, Movers, Flyers, KET, PET).
7Data model
| Entity | Fields | Notes |
|---|---|---|
| course (existing Course) | + session_duration_min int; age_from; age_to; default_capacity; level_code; is_template | extend |
| level (catalog) | id; tenant_id; code; name; framework enum(cefr, cambridge_yle, ielts, custom); score_from; score_to; position | new |
| room | id uuid; school_id; name; capacity; equipment text[]; is_active | new; unique (school_id, name) |
| class_profile (extends Class) | class_id int64 PK; school_id; course_id; level_code; main_staff_id; assistant_staff_id; room_id; capacity; trial_seats; start_date; end_date; planned_sessions; status enum; allow_makeup bool; pattern jsonb [{weekday, start, end}]; holidays date[] | new |
| class_session | id uuid; class_id; school_id; seq int; date; start_at; end_at (timestamptz); room_id; main_staff_id; substitute_staff_id null; status enum(planned, held, cancelled, cancelled_late); cancel_reason; is_makeup bool; is_adjusted bool; note; attendance_marked_at; created_at; updated_at | new; index (school_id, date), (room_id, start_at), (main_staff_id, start_at) |
| enrollment | id uuid; student_id; class_id; start_date; end_date null; status enum(active, ended, waiting); end_reason; fee_plan_id null; created_by; created_at | new; one active enrollment per (student, class) |
| makeup_booking | id; student_id; source_session_id (absence); target_session_id; consumes_package bool; status enum(booked, attended, no_show, cancelled) | new |
| holiday | id; tenant_id; date; name | new |
8API contract
| Method | Path | Request / response | Status |
|---|---|---|---|
| GET/POST/PUT/DELETE | /v3/teacher/school-finance/course/courses[/{id}] | as today + extension fields | extend |
| GET | /v3/teacher/classes | as today; response adds profile summary | extend |
| POST | /v3/school/classes | {school_id, course_id, name, code?, level_code, main_staff_id?, assistant_staff_id?, room_id, capacity, trial_seats, start_date, end_by: {sessions: n} | {date}, pattern[], holidays[], program?: CreateClassScheduleRequest fields} → {class, sessions[], conflicts[]}; dry_run=true returns preview only | new (creates Class + ClassSchedule internally) |
| GET/PATCH | /v3/school/classes/{id} | detail with profile, roster counts, next session | new |
| POST | /v3/school/classes/{id}/regenerate | {pattern, holidays, from_date} planned sessions only | new |
| POST | /v3/school/classes/{id}/close | /cancel | {reason} | new |
| GET | /v3/school/sessions | school_id, from, to, class_id, room_id, staff_id, status → Session[] with class, room, teacher, counts | new |
| POST | /v3/school/classes/{id}/sessions | {date, start, end, room_id, staff_id, is_makeup} one-off | new |
| PATCH | /v3/school/sessions/{id} | {date?, start?, end?, room_id?, main_staff_id?, substitute_staff_id?, note?, apply_to_following?: bool, override_conflict?: bool} → 409 CONFLICT {conflicts[]} | new |
| POST | /v3/school/sessions/{id}/cancel | {reason, add_makeup: bool, notify: bool} | new |
| GET/POST | /v3/school/classes/{id}/enrollments | {student_id, start_date, fee_plan_id?} → 409 CLASS_FULL (offers waiting) | new |
| PATCH | /v3/school/enrollments/{id} | {end_date, end_reason} or {transfer_to_class_id, effective_date} | new |
| GET | /v3/school/sessions/{id}/roster | enrolled + trial + makeup with flags and balance | new |
| POST | /v3/school/makeups | {student_id, source_session_id, target_session_id | new_session: {...}, consumes_package} | new |
| GET/POST/PATCH | /v3/school/rooms | new | |
| GET/POST/DELETE | /v3/school/holidays | new | |
| GET/POST/PATCH | /v3/school/levels | level catalog with score bands | new |
| GET | /v3/school/timetable | school_id, week, view=room|teacher|class → grouped sessions | new |
| GET | /v3/school/staff/{id}/timetable.ics | signed link | new |
| POST/GET/PUT | /v3/teacher/class-schedules[...] | FLYER program lessons; unchanged | existing |
9Permissions
| Code | owner | branch_manager | academic_admin | admissions | teacher |
|---|---|---|---|---|---|
classes.view | all | branch | branch | branch read | own |
classes.manage (create, edit, close) | yes | yes | yes | no | no |
sessions.manage (edit, cancel, substitute) | yes | yes | yes | no | request only |
classes.override_conflict | yes | yes | no | no | no |
enrollments.manage | yes | yes | yes | via convert | no |
makeups.manage | yes | yes | yes | no | no |
rooms.manage, courses.manage, levels.manage | yes | rooms | courses, rooms | no | no |
timetable.view | all | branch | branch | branch | own |
10Analytics events
| Event | Properties |
|---|---|
| class_created | level, weekdays, sessions, capacity, with_program |
| session_conflict_shown / session_conflict_overridden | type (room|teacher) |
| session_edited / session_cancelled | late (bool), makeup_added, notified |
| substitute_assigned | hours_before |
| enrollment_created / ended / transferred | reason, fill_rate_after |
| makeup_booked | consumes_package |
| timetable_viewed | view, printed |
| class_closed | sessions_held, sessions_cancelled |
11Edge cases
- Pattern with two sessions on the same weekday (morning and evening intensive): allowed; pattern is a list, not a map.
- Class spanning a Tết break: holidays skip and extend the end date; the preview shows the new end.
- Teacher removed from branch while assigned to future sessions: PRD 06 lists affected sessions for reassignment; sessions keep the staff id until changed.
- Room deactivated with future sessions: block deactivation until sessions are moved.
- Student enrolled mid-course: sessions before start date are not on their roster and not billed as absences.
- Time zone: all sessions in tenant timezone; DST not applicable in Vietnam but stored as timestamptz.
- Two admins editing the same session: optimistic concurrency with
updated_at; second save gets 409 STALE.
12Open questions
- Should a class be allowed without a room (online or off-site)? Proposed: yes, with room "Trực tuyến" as a virtual room per branch.
- Is a waiting list per class enough, or do centers keep a waiting list per level and time band?
- Do we need per-session different durations (exam day longer)? Proposed: edit per session covers it.
Trường Số (truongso.net) · FLYER internal