# 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 (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 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