Fitur lengkap: - Auth JWT + RBAC (Admin/Teacher) + halaman login - Shell 3 kolom (left nav, topbar, right rail dashboard) - CRUD siswa + follow up kanban + status calon/student/ex - Dashboard: siswa aktif, kehadiran, bar sumber lead, perlu follow up - Pembayaran prorata per pertemuan (cash/transfer/qris) - Kelas, enrollment, generate sesi, kehadiran bulk - Jurnal aktivitas + upload multi-foto - Raport + placement test (tampil di detail siswa) - Pengaturan & seed data contoh Stack: Next.js 16, TypeScript, Tailwind v4, Prisma (SQLite), Recharts, Zod
24 KiB
AGENTS.md — Kodeva
Kodeva — Sistem Manajemen Kursus Coding (Coding Course Management System). Dokumen ini adalah kontrak kerja untuk AI coding agent & developer. Baca sampai habis sebelum menulis kode. Untuk aturan visual/UX, lihat
DESIGN.md. Untuk contoh data, lihatseed.example.ts.
1. Ringkasan Produk
Kodeva membantu akademi/bimbel coding mengelola siklus hidup siswa dari calon student → student → ex student, lengkap dengan:
| # | Fitur | Ringkas |
|---|---|---|
| 1 | CRUD Follow Up Siswa | Data siswa: nama, no. telepon, email, foto profil, nama orang tua, sumber lead (WhatsApp / Referral / Banner / Social Media), status siswa |
| 2 | Dashboard Right Rail | Grafik siswa aktif, kehadiran, bar chart sumber lead |
| 3 | Pembayaran Prorata | Bayar per pertemuan, tanpa payment gateway, metode Cash / Transfer / QRIS |
| 4 | Jurnal & Raport & Kehadiran | Guru mengisi kehadiran, jurnal kelas, dan raport siswa |
| 5 | Konfigurasi Kelas | Kelas dapat dikonfigurasi; siswa dipilihkan kelas saat pendaftaran |
| 6 | Jurnal Aktivitas | Upload foto + aktivitas kelas |
| 7 | Dashboard Ringkasan | Berapa yang harus di-follow up, berapa ex student |
| 8 | Placement Test | Hasil tes penempatan tampil di detail siswa |
Pengguna: Admin (pemilik/operator), Teacher (tutor). Bahasa UI: Bahasa Indonesia.
2. Tech Stack (Default)
Runtime : Node.js 20+ / Bun
Framework : Next.js 15 (App Router, React Server Components)
Language : TypeScript (strict)
Styling : Tailwind CSS v4 + CSS variables (lihat DESIGN.md)
UI Kit : shadcn/ui + Radix Primitives + lucide-react
Charts : Recharts
Forms : React Hook Form + Zod (zodResolver)
Tables : TanStack Table
Drag & Drop : dnd-kit (kanban follow-up)
ORM : Prisma
Database : PostgreSQL 16 (dev cepat boleh SQLite, tapi schema final PostgreSQL)
Auth : Auth.js (NextAuth v5) — Credentials + RBAC
Upload : API route lokal `/api/uploads` → `public/uploads` (atau S3-compatible)
Dates : date-fns (+ locale `id`)
Testing : Vitest (unit) + Playwright (e2e)
Lint/Format : ESLint + Prettier
Package Mgr : pnpm
Jika user memilih stack lain (mis. Laravel/Vue), pertahankan domain model, business rules, dan DESIGN.md, hanya ganti implementasi. Tanyakan dulu sebelum migrasi stack.
3. Setup & Perintah
pnpm install # install dependency
cp .env.example .env # siapkan env
pnpm db:push # prisma db push (dev)
pnpm db:seed # isi contoh data (seed.example.ts)
pnpm dev # jalankan dev server :3000
pnpm build && pnpm start # production build
pnpm lint # eslint
pnpm typecheck # tsc --noEmit
pnpm test # vitest
pnpm test:e2e # playwright
pnpm format # prettier --write .
Definition of Done setiap task: pnpm lint && pnpm typecheck && pnpm test hijau, tidak ada any liar, tidak ada secret ter-commit.
4. Struktur Folder
kodeva/
├─ AGENTS.md
├─ DESIGN.md
├─ seed.example.ts
├─ prisma/
│ ├─ schema.prisma
│ └─ migrations/
├─ public/
│ └─ uploads/ # foto siswa, jurnal, bukti bayar
├─ src/
│ ├─ app/
│ │ ├─ (auth)/login/page.tsx
│ │ ├─ (app)/
│ │ │ ├─ layout.tsx # shell: left nav + main + right rail
│ │ │ ├─ dashboard/page.tsx
│ │ │ ├─ follow-up/page.tsx # kanban
│ │ │ ├─ students/
│ │ │ │ ├─ page.tsx
│ │ │ │ ├─ new/page.tsx
│ │ │ │ └─ [id]/page.tsx # detail: profil, kelas, kehadiran, bayar, raport, placement
│ │ │ ├─ classes/page.tsx
│ │ │ ├─ classes/[id]/page.tsx
│ │ │ ├─ attendance/page.tsx
│ │ │ ├─ payments/page.tsx
│ │ │ ├─ journals/page.tsx
│ │ │ ├─ report-cards/page.tsx
│ │ │ ├─ placements/page.tsx
│ │ │ └─ settings/page.tsx
│ │ └─ api/
│ │ ├─ students/route.ts
│ │ ├─ students/[id]/route.ts
│ │ ├─ ... (lihat §8)
│ │ └─ uploads/route.ts
│ ├─ components/
│ │ ├─ ui/ # shadcn primitives
│ │ ├─ layout/ # AppSidebar, RightRail, Topbar
│ │ ├─ students/ # StudentForm, StudentCard, StatusBadge
│ │ ├─ charts/ # ActiveStudentsChart, AttendanceChart, LeadSourceBar
│ │ ├─ payments/ # PaymentForm, ProrataSummary
│ │ └─ shared/ # EmptyState, StatCard, DataTable
│ ├─ lib/
│ │ ├─ prisma.ts
│ │ ├─ auth.ts
│ │ ├─ rbac.ts
│ │ ├─ validators/ # zod schemas per entitas
│ │ ├─ prorata.ts # kalkulasi pembayaran (PURE FUNCTION + unit test)
│ │ ├─ follow-up.ts # query due follow up
│ │ └─ utils.ts # cn(), formatIDR(), formatDateID()
│ ├─ hooks/
│ └─ types/
└─ tests/
Aturan: Server Components untuk baca data, Server Actions / Route Handlers untuk mutasi. Jangan fetch API internal dari Server Component — query Prisma langsung.
5. Konvensi Kode
- TypeScript strict. Dilarang
any; pakaiunknown+ narrowing.import typeuntuk tipe. - Naming: komponen
PascalCase, file komponenPascalCase.tsx, utilcamelCase.ts, route segmentkebab-case. - Validasi: semua input user divalidasi Zod di boundary (form + route handler) memakai schema yang sama.
- Uang: simpan sebagai
Intdalam rupiah penuh (tanpa sen). Jangan pakaiFloat. Format tampilan lewatformatIDR()→Rp 2.000.000. - Tanggal: simpan UTC di DB, tampilkan dengan
date-fnslocaleid(mis.Senin, 18 Sep 2026). Timezone defaultAsia/Jakarta. - ID:
cuid(). Jangan auto-increment untuk entitas publik. - Soft delete: entitas siswa/kelas pakai
archivedAt; jangan hard delete. - Query: selalu scope query ke authorization. Jangan pernah mengembalikan data lintas-tenant tanpa cek role.
- Error: route handler mengembalikan
{ data }atau{ error: { code, message } }; jangan bocorkan stack trace. - Commit: Conventional Commits (
feat(students): ...,fix(prorata): ...).
6. Domain Model (Prisma)
generator client { provider = "prisma-client-js" }
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
enum Role { ADMIN TEACHER }
enum StudentStatus { CALON_STUDENT STUDENT EX_STUDENT }
enum LeadSource { WHATSAPP REFERRAL BANNER SOCIAL_MEDIA }
enum FollowUpStatus { NEW CONTACTED TRIAL OFFER_SENT ENROLLED LOST }
enum Gender { MALE FEMALE }
enum ClassStatus { DRAFT ACTIVE COMPLETED ARCHIVED }
enum EnrollmentStatus { ACTIVE COMPLETED DROPPED }
enum AttendanceStatus { PRESENT LATE EXCUSED SICK ABSENT }
enum PaymentMethod { CASH TRANSFER QRIS }
enum PaymentStatus { UNPAID PARTIAL PAID REFUNDED }
enum PlacementLevel { BEGINNER INTERMEDIATE ADVANCED }
model User {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
role Role @default(TEACHER)
avatarUrl String?
phone String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
classesTaught Class[] @relation("ClassTeacher")
sessions Session[]
journals Journal[]
reportCards ReportCard[]
placementTests PlacementTest[] @relation("Examiner")
followUpsOwned FollowUp[] @relation("FollowUpPIC")
}
model Student {
id String @id @default(cuid())
fullName String
phone String
email String? @unique
photoUrl String?
parentName String?
parentPhone String?
birthDate DateTime?
gender Gender?
address String?
leadSource LeadSource
status StudentStatus @default(CALON_STUDENT)
notes String?
joinedAt DateTime?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enrollments Enrollment[]
attendances Attendance[]
payments Payment[]
reportCards ReportCard[]
placementTests PlacementTest[]
followUps FollowUp[]
journalStudents JournalStudent[]
}
model Class {
id String @id @default(cuid())
name String
code String @unique
description String?
level PlacementLevel
teacherId String?
teacher User? @relation("ClassTeacher", fields: [teacherId], references: [id])
totalMeetings Int @default(10)
packagePrice Int @default(0) // harga paket penuh (IDR)
pricePerMeeting Int? // override opsional
capacity Int @default(12)
schedule String? // "Senin & Rabu 16:00-17:30"
startDate DateTime?
endDate DateTime?
status ClassStatus @default(ACTIVE)
color String? // hex warna kartu kelas
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enrollments Enrollment[]
sessions Session[]
payments Payment[]
journals Journal[]
reportCards ReportCard[]
}
model Enrollment {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
status EnrollmentStatus @default(ACTIVE)
joinedAt DateTime @default(now())
leftAt DateTime?
createdAt DateTime @default(now())
@@unique([studentId, classId])
}
model Session {
id String @id @default(cuid())
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
meetingNumber Int
date DateTime
topic String?
teacherId String?
teacher User? @relation(fields: [teacherId], references: [id])
createdAt DateTime @default(now())
attendances Attendance[]
journals Journal[]
@@unique([classId, meetingNumber])
}
model Attendance {
id String @id @default(cuid())
sessionId String
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
status AttendanceStatus
note String?
recordedById String?
createdAt DateTime @default(now())
@@unique([sessionId, studentId])
}
model Payment {
id String @id @default(cuid())
invoiceNumber String @unique
studentId String
student Student @relation(fields: [studentId], references: [id])
classId String
class Class @relation(fields: [classId], references: [id])
meetingsPaid Int
pricePerMeeting Int
amount Int // meetingsPaid * pricePerMeeting
method PaymentMethod
status PaymentStatus @default(PAID)
paidAt DateTime
note String?
proofUrl String?
createdById String?
createdAt DateTime @default(now())
}
model Journal {
id String @id @default(cuid())
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
sessionId String?
session Session? @relation(fields: [sessionId], references: [id])
title String
description String?
activityDate DateTime
tags String[]
photos JournalPhoto[]
students JournalStudent[]
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model JournalPhoto {
id String @id @default(cuid())
journalId String
journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade)
url String
caption String?
sortOrder Int @default(0)
}
model JournalStudent {
journalId String
studentId String
journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@id([journalId, studentId])
}
model ReportCard {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
classId String
class Class @relation(fields: [classId], references: [id])
period String // "2026-Term1"
teacherId String
teacher User @relation(fields: [teacherId], references: [id])
attendanceSummary String?
finalScore Int?
grade String? // A / B / C / D
teacherNotes String?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scores ReportScore[]
}
model ReportScore {
id String @id @default(cuid())
reportCardId String
reportCard ReportCard @relation(fields: [reportCardId], references: [id], onDelete: Cascade)
aspect String // "Logika", "Computational Thinking", "Problem Solving", "Kreativitas", "Kolaborasi"
score Int // 0-100
note String?
}
model PlacementTest {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
testDate DateTime
levelResult PlacementLevel
recommendedClassId String?
totalScore Int
examinerId String?
examiner User? @relation("Examiner", fields: [examinerId], references: [id])
notes String?
createdAt DateTime @default(now())
scores PlacementScore[]
}
model PlacementScore {
id String @id @default(cuid())
placementTestId String
placementTest PlacementTest @relation(fields: [placementTestId], references: [id], onDelete: Cascade)
category String // "Logika", "Dasar Coding", "Problem Solving", "Kreativitas"
score Int // 0-100
}
model FollowUp {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
status FollowUpStatus @default(NEW)
picId String?
pic User? @relation("FollowUpPIC", fields: [picId], references: [id])
nextFollowUpAt DateTime?
lastContactedAt DateTime?
channel LeadSource?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities FollowUpActivity[]
}
model FollowUpActivity {
id String @id @default(cuid())
followUpId String
followUp FollowUp @relation(fields: [followUpId], references: [id], onDelete: Cascade)
type String // call | wa | email | meeting
note String
authorId String?
createdAt DateTime @default(now())
}
model Setting {
key String @id
value Json
}
Relasi penting: Student 1—N Enrollment N—1 Class. Satu siswa bisa ikut beberapa kelas. Kehadiran terikat ke Session (pertemuan), bukan langsung ke kelas — ini yang membuat prorata per pertemuan akurat.
7. Business Rules (WAJIB, sertakan unit test)
7.1 Prorata Pembayaran — src/lib/prorata.ts
Pembayaran dihitung per pertemuan, bukan per bulan.
pricePerMeeting = klass.pricePerMeeting ?? Math.round(klass.packagePrice / klass.totalMeetings)
amount = meetingsPaid * pricePerMeeting
Contoh: paket Rp 2.000.000 / 10 pertemuan → pricePerMeeting = 200.000.
- Bayar 10 pertemuan →
amount = 2.000.000(PAID penuh). - Bayar 5 pertemuan →
amount = 1.000.000(PAID, sisa 5 sesi). - Cicilan:
Payment.status = PARTIALbila akumulasimeetingsPaid< komitmen, lalu naik kePAID.
Nilai prorata saat siswa berhenti pindah/refund:
usedMeetings = count(Attendance WHERE studentId = X AND status IN (PRESENT, LATE))
paidMeetings = sum(Payment.meetingsPaid WHERE status != REFUNDED)
remainingMeetings = max(0, paidMeetings - usedMeetings)
proratedValue = remainingMeetings * pricePerMeeting // nilai sisa / refund
Aturan sesi: 1 pembayaran = N pertemuan. Kuota sesi siswa = total meetingsPaid. Sesi terpakai = kehadiran PRESENT/LATE. Jika sesi terpakai > kuota → tandai UNPAID & tampilkan badge "Perlu Perpanjang".
7.2 Follow Up Due — src/lib/follow-up.ts
followUpDue = FollowUp.findMany({
where: {
status: { notIn: ["ENROLLED", "LOST"] },
nextFollowUpAt: { lte: endOfToday() },
},
})
- Angka "Perlu Follow Up" di dashboard =
count(followUpDue). - Angka "Ex Student" =
count(Student where status = EX_STUDENT). - Kanban follow-up: kolom
NEW → CONTACTED → TRIAL → OFFER_SENT → ENROLLED;LOSTdi kolom terpisah. - Saat follow-up diubah ke
ENROLLED, tawarkan form: ubahStudent.statusjadiSTUDENT, pilih kelas, buatEnrollment, lalu arahkan ke inputPayment.
7.3 Kehadiran
attendanceRate = (PRESENT + LATE) / totalSesiTercatat * 100.LATEdihitung hadir tapi ditandai kuning.- Guru mengisi kehadiran per
Session(halamanattendance), bisa bulk (Hadir semua) lalu ubah pengecualian.
7.4 Status Siswa
CALON_STUDENT --(enroll + bayar)--> STUDENT --(selesai kelas / berhenti)--> EX_STUDENT
^ |
+---------- (daftar lagi) ----------------+
Perubahan status manual oleh Admin harus tercatat di activity/audit.
7.5 Placement Test
- Menyimpan skor per kategori (0–100) →
levelResult(BEGINNER/INTERMEDIATE/ADVANCED) + rekomendasi kelas. - Hasil selalu tampil di tab "Placement Test" pada halaman detail siswa dan sebagai badge di header profil.
7.6 Kelas
- Admin bisa CRUD kelas: nama, kode unik, level, guru, jumlah pertemuan, harga paket, kapasitas, jadwal, warna.
- Siswa baru wajib memilih kelas (minimal 1) saat diubah menjadi
STUDENT. Validasi kapasitas: tolak jikaenrollments.active >= capacity. - Generate
Sessionotomatis sebanyaktotalMeetingsdengan interval jadwal saat kelas diaktifkan (boleh manual).
8. API Endpoints
| Method | Route | Fungsi | Role |
|---|---|---|---|
| GET/POST | /api/students |
list (filter status/source/q) & create | ADMIN, TEACHER |
| GET/PATCH/DELETE | /api/students/[id] |
detail, update, archive | ADMIN, TEACHER |
| POST | /api/students/[id]/enroll |
enroll ke kelas | ADMIN |
| GET/POST | /api/follow-ups |
list (due) & create | ADMIN, TEACHER |
| PATCH | /api/follow-ups/[id] |
ubah status / jadwal / PIC | ADMIN, TEACHER |
| POST | /api/follow-ups/[id]/activities |
catat aktivitas follow up | ADMIN, TEACHER |
| GET/POST | /api/classes |
list & create kelas | ADMIN |
| GET/PATCH/DELETE | /api/classes/[id] |
detail/update/archive | ADMIN |
| POST | /api/classes/[id]/sessions |
generate/atur pertemuan | ADMIN, TEACHER |
| GET/POST | /api/attendance |
per session & bulk upsert | ADMIN, TEACHER |
| GET/POST | /api/payments |
list & buat pembayaran (prorata) | ADMIN |
| PATCH | /api/payments/[id] |
update status / refund | ADMIN |
| GET/POST | /api/journals |
feed & create (multipart foto) | ADMIN, TEACHER |
| GET/PATCH/DELETE | /api/journals/[id] |
detail/update/hapus | ADMIN, TEACHER |
| GET/POST | /api/report-cards |
list & create | ADMIN, TEACHER |
| PATCH | /api/report-cards/[id]/publish |
publish ke siswa | ADMIN, TEACHER |
| GET/POST | /api/placements |
list & create hasil placement | ADMIN, TEACHER |
| GET | /api/dashboard/summary |
angka + data grafik right rail | ADMIN, TEACHER |
| POST | /api/uploads |
upload file → URL | ADMIN, TEACHER |
Semua route pakai withAuth(handler, { roles }) dari src/lib/rbac.ts. Validasi body dengan Zod. Response sukses { data }, error { error: { code, message } }.
9. RBAC
| Area | ADMIN | TEACHER |
|---|---|---|
| Dashboard & right rail | ✅ | ✅ |
| CRUD siswa | ✅ | ✅ (tanpa archive) |
| CRUD follow up | ✅ | ✅ |
| CRUD kelas & setting | ✅ | 👁 baca saja |
| Input kehadiran | ✅ | ✅ (kelas sendiri) |
| Input jurnal & foto | ✅ | ✅ (kelas sendiri) |
| Input raport | ✅ | ✅ (kelas sendiri) |
| Input placement test | ✅ | ✅ |
| Pembayaran | ✅ | 👁 baca saja |
| Manajemen user | ✅ | ❌ |
10. Environment Variables
DATABASE_URL="postgresql://user:pass@localhost:5432/kodeva"
AUTH_SECRET="ganti-dengan-random-32-char"
AUTH_TRUST_HOST=true
NEXT_PUBLIC_APP_NAME="Kodeva"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
UPLOAD_DIR="./public/uploads"
MAX_UPLOAD_MB=5
TZ="Asia/Jakarta"
Jangan pernah commit .env. Sediakan .env.example.
11. Contoh Data
Dataset contoh lengkap ada di seed.example.ts: 3 user (1 admin, 2 teacher), 5 kelas, 10 siswa (campuran CALON_STUDENT / STUDENT / EX_STUDENT), enrollment, sesi, kehadiran, pembayaran prorata, jurnal + foto, raport, placement test, dan follow up.
Ringkasan untuk konteks cepat:
Kelas (5):
| Kode | Nama | Level | Pertemuan | Harga Paket | Rp/Pertemuan |
|---|---|---|---|---|---|
| KOD-SCR-01 | Scratch Junior | Beginner | 8 | Rp 1.200.000 | Rp 150.000 |
| KOD-PYT-01 | Python Dasar | Beginner | 10 | Rp 2.000.000 | Rp 200.000 |
| KOD-WEB-01 | Web Dev: HTML & CSS | Beginner | 12 | Rp 2.400.000 | Rp 200.000 |
| KOD-JS-01 | JavaScript Intermediate | Intermediate | 10 | Rp 2.500.000 | Rp 250.000 |
| KOD-ARD-01 | Arduino & Robotics | Intermediate | 8 | Rp 1.800.000 | Rp 225.000 |
Siswa (10): Andi Pratama (STUDENT), Citra Lestari (CALON_STUDENT), Dimas Anggara (STUDENT), Elsa Maharani (CALON_STUDENT), Fajar Nugroho (STUDENT), Gita Permata (EX_STUDENT), Hadi Susanto (STUDENT), Intan Puspita (CALON_STUDENT), Joko Wijaya (STUDENT), Kirana Dewi (EX_STUDENT).
Contoh prorata: Andi bayar Rp 2.000.000 untuk 10 pertemuan Python Dasar → Rp 200.000/pertemuan. Hadir 6 → terpakai Rp 1.200.000, sisa 4 pertemuan (Rp 800.000).
12. Testing & Quality
- Unit wajib untuk
prorata.ts,follow-up.ts,utilsformat uang/tanggal, dan semua Zod schema. - Integration untuk route handler (happy path + validasi gagal + unauthorized).
- E2E minimal: login → tambah calon siswa → follow up → enroll + bayar prorata → isi kehadiran → isi jurnal → isi raport → lihat placement test di detail siswa.
- Sertakan test case batas:
totalMeetings = 0,meetingsPaid > totalMeetings, pembayaran parsial, kehadiran melebihi kuota.
13. Do / Don't untuk Agent
DO
- Baca
DESIGN.mdsebelum membuat/mengubah UI. Pakai token warna & komponen yang sudah ada, jangan hardcode hex baru. - Jalankan
prisma generatesetelah mengubah schema. - Buat komponen kecil & reusable; jangan file 800 baris.
- Tangani 4 state: loading (skeleton), empty, error, success.
- Aksesibel: label form,
aria-*, fokus terlihat, kontras AA. - Format uang & tanggal lewat util, jangan inline.
DON'T
- Jangan tambahkan payment gateway / integrasi pihak ketiga pembayaran.
- Jangan pakai
Floatuntuk uang. - Jangan fetch internal API dari Server Component.
- Jangan hapus data keras; gunakan archive.
- Jangan ubah
DESIGN.md/palet tanpa persetujuan user. - Jangan taruh logika prorata di komponen; selalu di
lib/prorata.ts+ test.
14. Roadmap
- M1 Auth + shell layout (left nav, right rail) + Prisma schema + seed
- M2 CRUD Siswa + Follow Up kanban + status
- M3 CRUD Kelas + Enrollment + Sesi/Pertemuan
- M4 Kehadiran + Pembayaran prorata
- M5 Jurnal + upload foto
- M6 Raport + Placement Test
- M7 Dashboard right rail (semua grafik) + polish
- M8 Export, notifikasi WhatsApp manual, audit log