- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
Every student in the system is born through one shared core, provisionStudent, whichever door they came in by. The doors differ; everything after the core is the same for all of them.
Four channels, one assembly point
| Channel | Where | Application.channel | What the channel itself contributes |
|---|---|---|---|
| Apply | /{lang}/application (family) | PORTAL | A reviewed Application; staff select, the family accepts, an admin confirms |
| Admin single | /{lang}/students/add/{id}/… (admin) | ADMIN_DIRECT | A draft Student row filled step by step; Create runs the core |
| Onboarding CSV | School setup import | ONBOARDING_IMPORT | One core call per row, in its own transaction |
| Bulk CSV | /{lang}/school/bulk | BULK_IMPORT | Same engine as onboarding, plus DOCX tables and a "notify families" opt-in |
What the core guarantees for all four: a User with role STUDENT (an existing applicant account is promoted, a new one is created with a school-scoped code such as 25030002), a channel-tagged Application (the portal's real one, or a hidden "direct admission" shadow for the other three), the AcademicGrade + StudentYearLevel, fee assignments and one invoice per instalment through ensureStudentFeeAssignments, guardians through createOrLinkGuardian, copied documents, and — after the transaction commits — the account notice, the fee notice and a guardian notice through notifyProvisionedStudent.
Everything after the core is shared, not per channel:
- Placement. The admission
PlacementDialogis the one placement UI. The Enrollment tab offers it to portal admits; the students list offers Assign Section on any provisioned student who has a grade and no seat, so a wizard or CSV student never has to be re-run through a wizard to get a seat. Placing a student puts them on the section's roster, timetable and attendance, and gives them their grade's subjects in Lumos (filtered by stream; moving grade never takes last year's courses away). - Money. Fee assignment happens in the core; the fee-due and fee-overdue crons then remind on instalment and invoice due dates; a payment allocates to invoices and links the payer to a receipt.
- Tracking. The Applications tab lists every channel (Channel column + facet), the student profile links to its application and back.
- Telling the family. One dispatcher, one notice set, in the family's language. Bulk imports queue their notices; single adds send inline.
The admin wizard
Four steps under /{lang}/students/add/{id}/. The "+" on the students list mints a draft Student (wizardStep: "attachments") and opens the wizard; the bare /students/add URL does the same.
| # | Step | Route | Collects | Required |
|---|---|---|---|---|
| 1 | Attachments | /attachments | photo, degree, transcript, ID, resume, other — uploads trigger AI auto-fill for later steps | No |
| 2 | Personal | /personal | name (single or split per the school's nameFormat), DOB, gender, phone, WhatsApp; Father / Mother tabs with name, phone, WhatsApp | name + one parent |
| 3 | Location | /location | address, city, state, postal code, country (Mapbox picker when configured) | No |
| 4 | Academic | /academic | previous school, grade, stream (grades 10–12), section — from the school's own rows | No |
Each step saves on Next through its own server action; the footer's Skip & Create is available once Personal is complete. Both exits run the same finisher: it calls completeStudentWizard, toasts any provisioning warnings (no fee structure for the grade, no grade so no fees, a seat in a grade with no classes), and hands the minted login to the credentials dialog that opens on the list page. The same wizard is the edit surface for an enrolled student ("Update"); the per-step actions persist the fields, and the finisher recognises an already-provisioned row and does not run the core again.
Every wizard action — the four step files and the draft/complete/delete actions — goes through one guard, wizard/authorize.ts, which asserts a session AND a role that may perform the operation, because the tenant is resolved from the subdomain before the session and a school-only check is callable unauthenticated.
Draft lifecycle
1. "+" (or /students/add) → createDraftStudent → Student { wizardStep: "attachments" }
2. Each step persists its fields; the footer records the current step
3. Personal complete → Create / Skip & Create → completeStudentWizard → provisionStudent
→ wizardStep: null, User + code, ADMIN_DIRECT Application, fees + invoices, notices
4. Drafts show a "Draft" badge and reopen at their step; deleteDraftStudent removes an abandoned one
A draft carries stub values for its NOT NULL columns (DOB 2000-01-01, gender male); the wizard presents them as empty so the admin fills them, and a value the admin does not enter stays the stub.
A row with no wizardStep is past the wizard — built by the core, or older than it. Opening it in the wizard is edit mode: every step saves its own fields, and the final Update returns to the list without running the core again and without the "name + one parent" gate, which guards provisioning only. Legacy rows keep whatever code, status, dates and application link they have; giving them a login and a channel-tagged application is the backfill script's job, never a Save.
The students list
/{lang}/students — server-rendered first page, load-more and search through getStudents, both ordering through the same buildStudentOrderBy so sorting a derived column (name, grade, classroom, phone) never reaches Prisma raw.
- Unplaced chip (
?unplaced=seat|grade|any) — students with no grade or no seat. A gradeless student is assigned no fees and raises no invoices, so this chip and the weekly admin alert in the fee-due cron are how they are found. - Row actions: View, Edit (wizard), grades / attendance / classes, Assign Section (admission
placeStudentspermission, shown when the student has a grade and no seat), Generate Credentials, Link Parent (access code), Archive / Restore / Permanently delete. - Dialog state lives in module stores (credentials, access code, placement): the table remounts when a Server Action completes, and a
useStateflag was wiped before the dialog's data arrived. Platform tables also adopt the server's rows after arouter.refresh()so a row an action just changed stops offering the same action.
Component structure
src/components/school-dashboard/listings/students/
├── content.tsx # Server — list page (rows, grade facet, unplaced filter)
├── table.tsx # Client — DataTable, toolbar, dialogs
├── columns.tsx # Client — columns + row actions (Assign Section, credentials…)
├── actions.ts # getStudents, update/archive/restore/purge, credentials, CSV
├── list-params.ts # nuqs parsers + buildStudentOrderBy + buildUnplacedFilter
├── authorization.ts # RBAC matrix (create/read/update/delete/enroll/link_guardian…)
├── placement-store.ts # Module store for Assign Section (target + fetched sections)
├── placement-dialog.tsx # Store-driven wrapper around admission/placement-dialog
├── access-code-store.ts / access-code-dialog.tsx
└── wizard/
├── authorize.ts # The one auth + role guard for every wizard action
├── actions.ts # getStudentForWizard, createDraftStudent, completeStudentWizard, …
├── finish.ts # The one wizard finisher (warnings + credentials dialog)
├── config.ts # 4 steps, groups, retired-step redirects
├── use-student-wizard.ts
├── attachments/ # Step 1 (shared attachments schema with the public wizard)
├── personal/ # Step 2 — student + father/mother tabs
├── location/ # Step 3
└── academic/ # Step 4 — grade / stream / section, admission card in edit mode
The shared core and its helpers live outside the block: src/lib/student-provisioning.ts, src/lib/student-provisioning-notify.ts, src/lib/system-campaign.ts, src/lib/fee-auto-assign.ts, src/lib/guardian-utils.ts, src/lib/enrollment-sync.ts.
Guardians
Both one-by-one channels write guardians through createOrLinkGuardian: a GuardianType (father / mother / guardian), a Guardian found by email or by phone (school-wide on purpose — siblings share a parent — but never resolving to this student's other parent), the StudentGuardian link, a primary phone row and, when given, a second phone row typed whatsapp.
Guardian types are matched by role, not by spelling. The demo seed stores the parents as الأب / الأم, other seeds as Father, the wizard and admission ask for father / mother. canonicalGuardianRole and PARENT_GUARDIAN_TYPE_NAMES in src/lib/guardian-utils.ts are the only place that knows the spellings; createOrLinkGuardian links under whichever spelling the school already has and creates the requested name only when it has none, so a school never grows a second type row per role — and its "never resolve to this student's other parent" exclusion compares roles, so a father already linked under an older twin row is recognised on re-save rather than duplicated. Anything that asks "does this student have a parent" (the wizard's provisioning gate, the personal step's father/mother tabs) goes through those helpers. The public application stores a guardian's WhatsApp in its *Email columns (it has no other column); enrollment classifies those by shape so a phone number never becomes a guardian's email address.
Keeping the invariant true
"Every student is born from an Application" holds only while every write goes through provisionStudent. A lint rule (STUDENT_CREATE_ALLOWLIST in eslint.config.mjs) and a test (src/tests/school-dashboard/listings/student-intake-invariant.test.ts) reject a db.student.create outside the core, the seeds, snapshot restore, ops scripts and createDraftStudent.
Known gaps
- A student with a grade and no fee assignment (the school has no fee structure for the campaign's academic year) is invisible to the reminders and to the unplaced chip — only a transient warning toast at creation says so.
- The wizard's admission card prints the system campaign's stored name unlocalised.
PhoneFieldwith no country selected stores a leading+on a local number.- Schools seeded in Arabic still carry the English twin guardian-type rows created before types were matched by role; every reader copes, but the twins are not merged.
See also
- Admission — the portal channel in detail, the enrollment transaction, placement, the journey audits
- Application — the family-facing wizard
- Fees · Invoice — what the core assigns and what the crons chase
Contact under the name
The listing's Name cell shows the student's phone or email under the name. Each school picks which
at School → Configuration → Contact under name (/school/configuration/name-contact), stored as
School.studentNameContact ("phone" default, or "email"). If the chosen value is missing for a
student, the other one is shown.