Files
Amadea 61ce9d7926 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
2026-09-18 19:24:59 +07:00

609 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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