- Given permission
staff.manage - When I submit full name, staff code, job title, employment type, branches, start date, phone, and optionally "Tạo tài khoản đăng nhập" with role
- Then the staff record is created; if an account was requested, an invitation is sent by email or SMS, the user is added to the school with the chosen role, and a
TeacherProfileis created when the role includes teaching. - Given the phone already belongs to an existing staff in the tenant
- When I submit
- Then the form shows the existing record and offers "Mở hồ sơ" instead of creating a duplicate.
PRD 06 · Teachers and staff
Staff directory, teacher profiles, contracts, pay rules per session or hour, timesheets derived from attendance, and periodic evaluations. Builds on the existing TeacherProfile and school membership; HR entities are new.
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:
- How the competitor's "thống kê giờ dạy giáo viên" is computed (from attendance or from timetable) and whether substitutes are handled.
- Their "quản lý tiền lương" fields: fixed salary, per-session, allowances, deductions; whether payslips are generated.
- Whether staff without login exist in their HR list.
- Teacher weekly schedule screen layout ("xếp lịch giáo viên theo tuần").
Proposed design
Nine screens share the School OS shell with the Staff nav item active, moving from directory to profile, contracts, pay rules, timesheets and evaluations; the key thing to see is that payable amounts are derived from attendance data, while every exception (substitute, late cancellation, teacher flag, adjustment, locked month) appears as its own line with a reason rather than being typed in.









1Purpose
A center's largest cost is teaching staff, and its most frequent operational question is "who taught how many sessions this month and what do we owe them". The module keeps one staff record per person across branches, links teachers to classes and sessions, computes payable sessions from attendance already recorded in PRD 04, and stores contracts and evaluations so that the owner can decide on renewals with evidence.
Goals
- One staff record per person, with system access (user account) optional. Cleaners and part-time tutors exist in the directory without logging in.
- Payable amount per teacher per month derived, not typed. Manual adjustments are explicit lines with reasons.
- Contract and document expiry surfaces 30 days ahead.
Out of scope
- Payroll tax, social insurance calculation, payslip generation as a legal document. The module exports a payroll sheet for the accountant's payroll tool.
- Leave requests and approvals (phase 3 candidate).
- Recruitment pipeline.
2What exists today
| Capability | Where | Status |
|---|---|---|
| School membership with roles OWNER / ADMIN / TEACHER | SchoolRole enum; GET /v3/teacher/schools, GET /v3/crm/me/accessible-schools | existing |
| Teacher profile (name, phone, email, user_name) | TeacherProfile schema; embedded on Tuition and Class objects | existing |
| Teacher performance rollup (students, tests assigned, completion rate) | GET /v3/teacher/viewer/rollup-teachers (RollupTeacher) | existing |
| CRM sales staff (SaleProfile) with CRM roles | /v3/crm/sale-profiles, /v3/crm/roles | existing |
| Contracts, pay rules, timesheets, evaluations, documents, branch assignment of staff | none | new |
Decision: introduce a staff entity that wraps an optional user_id, an optional teacher_profile_id and an optional sale_profile_id, so that one person shows once in the directory regardless of how many FLYER profiles they carry.
3Personas and roles
| Persona | Role | Needs |
|---|---|---|
| Owner | owner | Headcount and cost per branch, contract renewals, evaluation results. |
| Branch manager | branch_manager | Assign teachers to classes, approve timesheets, run evaluations. |
| Accountant | accountant | Monthly payroll sheet, pay rule maintenance, adjustments. |
| Academic admin | academic_admin | Find substitute teachers, keep profiles and documents current. |
| Teacher | teacher | See own schedule, own timesheet, own evaluation; confirm sessions. |
4User stories and acceptance criteria
- Given teachers exist in the school (from
/v3/teacher/schoolsmembership) without a staff record - When I open Staff → "Chưa có hồ sơ (N)"
- Then each unlinked teacher is listed with a "Tạo hồ sơ" action that pre-fills the form from
TeacherProfile.
- Given a staff record
- When I add contract type, start, end, pay rule, signed document
- Then the contract is saved; contracts cannot overlap in time for the same staff; 30 days before end the staff row shows Sắp hết hạn and a notification goes to branch_manager and accountant.
- Given permission
staff.pay_rules - When I create a rule of type per_session, per_hour, or monthly with rate, optional per-class-type overrides, and effective dates
- Then the rule applies to sessions whose date falls in the effective range; a session uses the most specific rule (class-type override > default).
- Given sessions in PRD 04 with status
heldand a recorded teacher (main or substitute) - When I click "Tạo bảng công tháng 9/2026"
- Then one timesheet per teacher is created with one line per held session (date, class, duration, rate, amount) plus lines for cancelled-late sessions if the pay rule pays them; totals are computed; status
draft. - Given a session was edited in PRD 04 after the timesheet was generated
- When the timesheet is viewed
- Then a banner "Có 2 buổi thay đổi sau khi tạo bảng công" offers "Tính lại".
- Given a draft timesheet for me
- When I open Bảng công of the month
- Then I see every line, can flag a line with a comment ("tôi dạy thay buổi này"), and press "Xác nhận"; flagged lines go to branch_manager for resolution; the timesheet moves to
confirmedonly when no flags remain open.
- Given timesheets in
confirmed - When the branch manager approves
- Then status
approved, lines locked; the accountant exports XLSX with one row per teacher (code, name, sessions, hours, base amount, adjustments, total) and per-session detail sheet; the export is logged.
- Given an evaluation template (criteria with weights, 1–5 scale)
- When I score a teacher for a period and add comments
- Then a weighted score is stored; the teacher sees the result after the manager marks it "Đã chia sẻ"; the profile shows a trend across periods alongside the existing performance rollup (students, completion rate).
- Given a staff record with an active account
- When I set status Đã nghỉ with end date
- Then future class assignments are cleared and listed for reassignment, the account is deactivated at end of day on the end date, history remains visible.
5Screens
Tabs: Đang làm việc (N) · Sắp hết hạn HĐ (N) · Chưa có hồ sơ (N) · Đã nghỉ. Filters: search (name, code, phone), branch, job title, employment type. Actions: + Thêm nhân sự, Xuất Excel.
| Column | Notes |
|---|---|
| Mã NV | staff.code |
| Họ tên | avatar + name; link to profile |
| Chức danh | job_title |
| Loại | chip: Toàn thời gian / Bán thời gian / Cộng tác viên |
| Chi nhánh | chips, "+2" overflow |
| Lớp đang dạy | count (teachers only), link |
| Hợp đồng | end date + state chip |
| Tài khoản | chip: Đã kích hoạt / Chờ mời / Không có |
| Thao tác | kebab: Sửa, Vô hiệu hoá, Gửi lại lời mời |
Empty state: "Chưa có nhân sự. Thêm giáo viên và nhân viên để phân lớp và tính công." Sort default by name (vi collation).
Header: avatar, name, code, job title, status chip, branches, buttons Sửa · Tạo tài khoản/Đặt lại mật khẩu · Vô hiệu hoá. Tabs: Tổng quan · Lớp & lịch · Hợp đồng · Bảng công · Đánh giá · Tài liệu · Nhật ký.
- Tổng quan: two columns: personal info (birthday, gender, ID number masked, address, emergency contact) and work info (start date, employment type, pay rule summary, account role). Right rail: KPI from
RollupTeacherwhen the staff is a teacher: students, assigned tests, completion rate, growth rate. - Lớp & lịch: table of current classes (name, schedule text, students, room) and a week view of sessions (from PRD 03) with substitute markers.
- Hợp đồng: list with type, start, end, pay rule, document, status; + Thêm hợp đồng.
- Bảng công: month selector; table of lines; totals; status; teacher sees own only.
- Đánh giá: list of evaluations with score and trend sparkline; + Đánh giá mới for managers.
- Tài liệu: uploads with type (CCCD, bằng cấp, chứng chỉ, hợp đồng, khác), expiry date, expiring soon chip.
- Nhật ký: audit entries for this staff.
| Field | Type | Validation |
|---|---|---|
| Họ và tên | text | required, 2–80 |
| Mã nhân viên | text | auto NV-####, editable, unique |
| Chức danh | select (tenant list) + inline add | required |
| Loại hình | enum full_time / part_time / contractor | required |
| Chi nhánh | multi-select | ≥ 1 |
| Ngày bắt đầu | date | required, ≤ today + 90 d |
| Số điện thoại | tel | required, VN format, duplicate check |
| required if creating account | ||
| Ngày sinh, Giới tính, CCCD, Địa chỉ | optional; CCCD 12 digits | |
| Liên hệ khẩn cấp | name + phone | optional |
| Step 2: Tạo tài khoản đăng nhập | toggle | if on: role (from PRD 10 roles), branches scope, "Là giáo viên" flag creating TeacherProfile |
Table: name, type (Theo buổi / Theo giờ / Theo tháng), base rate, overrides count, effective from/to, staff count using it. Form: name, type, rate (VND), overrides table (class type or course → rate), pay cancelled-late sessions (toggle, percent), effective dates. Assigning a rule to staff happens on the contract.
Month picker, branch filter, status chips (Nháp / Chờ xác nhận / Đã xác nhận / Đã duyệt). Table: teacher, sessions, hours, base amount, adjustments, total, status, flags count. Row opens the timesheet detail: line table (date, class, session title, duration, teacher role main/substitute, rate, amount, flag), adjustments section (+ Thêm điều chỉnh: amount signed, reason), buttons per status: Tính lại · Gửi GV xác nhận · Duyệt · Xuất Excel.
Template selector, period (month or quarter), criteria rows with 1–5 radio and weight display, overall comment, "Chia sẻ với giáo viên" toggle. Score computed live in the header.
6Business rules
- A staff record belongs to one tenant and one or more branches; class assignment is limited to the staff's branches.
- A person may have several contracts over time but never two active contracts overlapping.
- Timesheet lines come only from PRD 04 sessions with status
heldorcancelled_late(when the rule pays them); sessions with statusplannedorcancellednever pay. - The substitute teacher is paid for the session, not the main teacher, unless the pay rule has "trả cả GV chính khi có dạy thay" enabled (default off).
- Rate snapshot: each timesheet line stores the rate used; later rule edits do not change approved timesheets.
- Adjustments require a reason and permission
staff.timesheets.adjust; they are audited. - Deactivating an account does not delete the staff record; the code remains reserved.
- Evaluation results are visible to the teacher only after sharing; owners and branch managers of the staff's branches always see them.
7Data model
| Entity | Fields | Notes |
|---|---|---|
| staff | id uuid; tenant_id; code; full_name; job_title_id; employment_type enum(full_time, part_time, contractor); status enum(active, on_leave, terminated); user_id int64 null; teacher_profile_id int64 null; sale_profile_id int64 null; phone; email; birthday; gender; id_number (encrypted); address; emergency_name; emergency_phone; start_date; end_date; branch_ids uuid[]; created_at; updated_at | unique (tenant_id, code); unique (tenant_id, phone) |
| job_title | id; tenant_id; name; is_teaching bool; position | defaults: Giáo viên, Trợ giảng, Giáo vụ, Tư vấn, Kế toán, Quản lý |
| staff_contract | id; staff_id; type enum(labour, service, probation); start_date; end_date null; pay_rule_id; document_file_id; status enum(active, expired, terminated); note | exclusion constraint on overlapping [start, end] |
| pay_rule | id; tenant_id; name; type enum(per_session, per_hour, monthly); base_rate numeric; pay_cancelled_late bool; cancelled_late_pct int; pay_main_when_substituted bool; effective_from; effective_to | |
| pay_rule_override | id; pay_rule_id; class_type text null; course_id uuid null; rate numeric | most specific wins |
| timesheet | id; tenant_id; branch_id; staff_id; year; month; status enum(draft, pending_confirm, confirmed, approved); base_amount; adjustment_amount; total_amount; generated_at; confirmed_at; approved_by; approved_at | unique (staff_id, year, month) |
| timesheet_line | id; timesheet_id; session_id (PRD 04); date; class_id; title; duration_min; role enum(main, substitute); rate; amount; flag_comment null; flag_status enum(none, open, resolved) | session_id unique per timesheet |
| timesheet_adjustment | id; timesheet_id; amount signed; reason; created_by; created_at | |
| evaluation_template | id; tenant_id; name; criteria jsonb [{key, label, weight}] | |
| evaluation | id; staff_id; template_id; period_start; period_end; scores jsonb {key: 1..5}; weighted_score numeric; comment; evaluator_id; shared_at null; created_at | |
| staff_document | id; staff_id; type enum(id_card, degree, certificate, contract, other); file_id; expires_on null; created_at |
8API contract
| Method | Path | Request / notes | Status |
|---|---|---|---|
| GET | /v3/teacher/schools | membership and roles of the current user | existing |
| GET | /v3/teacher/viewer/rollup-teachers | teacher KPIs for the profile rail | existing |
| GET | /v3/school/staff | q, branch_id, job_title_id, employment_type, status, unlinked=true, page, limit | new |
| POST | /v3/school/staff | {full_name, code?, job_title_id, employment_type, branch_ids, start_date, phone, email?, ..., account?: {role_id, branch_ids, is_teacher}} | new |
| GET / PATCH | /v3/school/staff/{id} | detail includes contracts, current pay rule, account status | new |
| POST | /v3/school/staff/{id}/account | {role_id, branch_ids, is_teacher} creates user invite + membership + TeacherProfile | new |
| POST | /v3/school/staff/{id}/link | {user_id | teacher_profile_id | sale_profile_id} | new |
| POST | /v3/school/staff/{id}/terminate | {end_date, reason} | new |
| GET / POST | /v3/school/staff/{id}/contracts | {type, start_date, end_date?, pay_rule_id, document_file_id?} | new |
| PATCH | /v3/school/staff/contracts/{id} | new | |
| GET / POST / PATCH | /v3/school/pay-rules[/{id}] | with overrides[] | new |
| POST | /v3/school/timesheets/generate | {year, month, branch_id?, staff_ids?} idempotent: regenerates drafts only | new |
| GET | /v3/school/timesheets | year, month, branch_id, status, staff_id | new |
| GET | /v3/school/timesheets/{id} | lines, adjustments, flags | new |
| POST | /v3/school/timesheets/{id}/submit | confirm | approve | recalculate | state transitions; 409 on invalid transition | new |
| POST | /v3/school/timesheets/{id}/lines/{line_id}/flag | {comment}; resolve via PATCH flag_status | new |
| POST | /v3/school/timesheets/{id}/adjustments | {amount, reason} | new |
| GET | /v3/school/timesheets/export | year, month, branch_id, format=xlsx | new |
| GET / POST | /v3/school/evaluation-templates | new | |
| GET / POST | /v3/school/staff/{id}/evaluations | {template_id, period_start, period_end, scores, comment, share} | new |
| GET / POST / DELETE | /v3/school/staff/{id}/documents | {type, file_id, expires_on} | new |
9Permissions
| Code | owner | branch_manager | accountant | academic_admin | teacher |
|---|---|---|---|---|---|
staff.view | yes | branch | yes | branch | self + colleagues' name/title only |
staff.manage | yes | branch | no | branch | no |
staff.accounts (invite, deactivate) | yes | branch | no | no | no |
staff.contracts | yes | view | yes | no | self view |
staff.pay_rules | yes | no | yes | no | no |
staff.timesheets.view | yes | branch | yes | no | self |
staff.timesheets.generate | yes | no | yes | no | no |
staff.timesheets.approve | yes | branch | no | no | no |
staff.timesheets.adjust | yes | no | yes | no | no |
staff.evaluations | yes | branch | no | no | self view when shared |
staff.export | yes | no | yes | no | no |
10Analytics events
| Event | Properties |
|---|---|
| staff_created | employment_type, with_account, is_teacher |
| staff_linked | link_type |
| contract_created | type, has_document, pay_rule_type |
| contract_expiring_notified | days_before |
| timesheet_generated | month, staff_count, line_count |
| timesheet_flagged | line_count |
| timesheet_approved | total_amount_bucket |
| payroll_exported | staff_count |
| evaluation_shared | score_bucket |
11Edge cases
- Teacher works at two branches with different rates: rate override by branch is not supported in v1; use two pay rules on two contracts is also not allowed (overlap). Decision: pay_rule_override gains an optional
branch_iddimension in v1. - Session held by two teachers (main + assistant): the session carries
assistant_staff_id; assistants are paid via a rule flaggedrole=assistant. - Month boundary: a session on 30/09 23:00 belongs to September by session date in tenant timezone.
- Timesheet regenerated after approval is refused; a correction goes to next month as an adjustment with reference.
- Staff terminated mid-month keeps the timesheet for sessions before end date.
- Account email already used by a member of another school: linking works (FLYER accounts are global); the staff record stays tenant-local.
12Open questions
- Should timesheets include non-teaching staff (fixed monthly) at all, or is the module teacher-only in phase 2?
- Do centers pay per student present (some do for group tutoring)? Would add a
per_student_presentrule type. - Is evaluation by parents (rating in the parent portal) an input to the evaluation score?
Trường Số (truongso.net) · FLYER internal