feat: implementasi Kodeva - sistem manajemen kursus coding

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
This commit is contained in:
Amadea
2026-09-18 19:24:59 +07:00
commit 61ce9d7926
86 changed files with 13392 additions and 0 deletions
+608
View File
@@ -0,0 +1,608 @@
# 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`](./DESIGN.md). Untuk contoh data, lihat [`seed.example.ts`](./seed.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
```bash
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`; pakai `unknown` + narrowing. `import type` untuk tipe.
- **Naming:** komponen `PascalCase`, file komponen `PascalCase.tsx`, util `camelCase.ts`, route segment `kebab-case`.
- **Validasi:** semua input user divalidasi Zod di boundary (form + route handler) memakai schema yang sama.
- **Uang:** simpan sebagai `Int` dalam **rupiah penuh** (tanpa sen). Jangan pakai `Float`. Format tampilan lewat `formatIDR()``Rp 2.000.000`.
- **Tanggal:** simpan UTC di DB, tampilkan dengan `date-fns` locale `id` (mis. `Senin, 18 Sep 2026`). Timezone default `Asia/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)
```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.
```ts
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 = PARTIAL` bila akumulasi `meetingsPaid` < komitmen, lalu naik ke `PAID`.
**Nilai prorata saat siswa berhenti pindah/refund:**
```ts
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`
```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`; `LOST` di kolom terpisah.
- Saat follow-up diubah ke `ENROLLED`, tawarkan form: ubah `Student.status` jadi `STUDENT`, pilih kelas, buat `Enrollment`, lalu arahkan ke input `Payment`.
### 7.3 Kehadiran
- `attendanceRate = (PRESENT + LATE) / totalSesiTercatat * 100`.
- `LATE` dihitung hadir tapi ditandai kuning.
- Guru mengisi kehadiran per `Session` (halaman `attendance`), 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 (0100) → `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 jika `enrollments.active >= capacity`.
- Generate `Session` otomatis sebanyak `totalMeetings` dengan 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
```env
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`](./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`, `utils` format 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.md` sebelum membuat/mengubah UI. Pakai token warna & komponen yang sudah ada, jangan hardcode hex baru.
- Jalankan `prisma generate` setelah 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 `Float` untuk 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