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
+8
View File
@@ -0,0 +1,8 @@
DATABASE_URL="file:./dev.db"
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"
+51
View File
@@ -0,0 +1,51 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# database (local SQLite)
prisma/*.db
prisma/*.db-journal
*.db
*.db-journal
# uploaded files
public/uploads/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+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
+458
View File
@@ -0,0 +1,458 @@
# DESIGN.md — Kodeva Design System
> Panduan visual & UX untuk **Kodeva**. Tema: **Colorful · Ed-Tech · Playful · Rapi (bukan childish)**.
> Bayangkan antarmuka produk SaaS modern untuk akademi coding — ceria, berenergi, tapi tetap kredibel dan enak dipakai kerja 8 jam.
---
## 1. Brand & Personality
| Atribut | Nilai |
|---------|-------|
| Nama | **Kodeva** |
| Tagline | *"Kelola kelas coding, tumbuhkan pembuat masa depan."* |
| Karakter | Ceria, cerdas, hangat, terpercaya, sedikit jenaka |
| Bukan | Kaku korporat, norak, kekanak-kanakan (tanpa kartun balita / font comic) |
| Metafora | Papan tulis digital yang penuh warna, blok kode berwarna, sticky notes |
**Referensi rasa:** Linear (kerapian) × Duolingo (keceriaan) × Notion (keruang) × Figma (warna).
---
## 2. Design Principles
1. **Warna membawa makna, bukan sekadar hiasan.** Tiap warna punya peran semantik (status, sumber lead, kehadiran). Konsisten di seluruh app.
2. **Playful lewat bentuk & gerak, bukan lewat font kekanak-kanakan.** Sudut membulat, gradient halus, blob dekoratif tipis, micro-animation.
3. **Data dulu.** Angka penting (perlu follow up, siswa aktif) langsung terlihat tanpa klik.
4. **Satu layar, satu fokus.** Hierarki jelas: judul → aksi utama → konten → detail.
5. **Hangat & manusiawi.** Foto profil bulat, sapaan personal ("Hai, Sari 👋"), bahasa Indonesia santai-profesional.
---
## 3. Color System
### 3.1 Brand Palette
| Token | Hex | Preview | Pemakaian |
|-------|-----|---------|-----------|
| `--brand-50` | `#F2EFFE` | 🟪 | background lembut |
| `--brand-100` | `#E4DEFD` | 🟪 | chip, hover |
| `--brand-300` | `#B9A9FB` | 🟪 | border aktif |
| `--brand-500` | `#7C5CFC` | 🟪 | **primary**, tombol, ikon aktif |
| `--brand-600` | `#6A46EF` | 🟪 | hover primary |
| `--brand-700` | `#5533CE` | 🟪 | pressed / teks di bg terang |
### 3.2 Accent & Semantic
| Token | Hex | Makna |
|-------|-----|-------|
| `--coral-500` | `#FF7A59` | aksi sekunder, highlight, CTA promo |
| `--amber-500` | `#FFB020` | warning, `CALON_STUDENT`, banner |
| `--mint-500` | `#12B886` | success, `STUDENT` aktif, hadir |
| `--sky-500` | `#2AA9FF` | info, social media, link |
| `--pink-500` | `#F65FA7` | aksen dekoratif, tag kreatif |
| `--danger-500` | `#F04438` | error, alpa, hapus |
| `--violet-soft` | `#A78BFA` | grafik pelengkap |
### 3.3 Neutral
| Token | Hex | Pemakaian |
|-------|-----|-----------|
| `--ink-900` | `#131A2B` | teks utama, heading |
| `--ink-700` | `#3A4560` | teks sekunder |
| `--ink-500` | `#6B7590` | placeholder, caption |
| `--ink-300` | `#C4CAD8` | border |
| `--ink-100` | `#EEF1F7` | divider, bg hover |
| `--surface` | `#FFFFFF` | kartu |
| `--canvas` | `#F7F8FC` | background halaman |
| `--canvas-tint` | linear-gradient(180deg,#F7F8FC 0%,#F1F0FE 100%) | background app |
### 3.4 Gradient (signature)
```css
--grad-primary: linear-gradient(135deg, #7C5CFC 0%, #2AA9FF 100%); /* tombol utama, hero */
--grad-coral: linear-gradient(135deg, #FF7A59 0%, #FFB020 100%); /* kartu highlight */
--grad-mint: linear-gradient(135deg, #12B886 0%, #2AA9FF 100%); /* stat positif */
--grad-pink: linear-gradient(135deg, #F65FA7 0%, #7C5CFC 100%); /* dekorasi */
```
Gunakan gradient **hanya** untuk: tombol primary, avatar ring, header kartu statistik, dan 1 blob dekoratif per halaman. Jangan gradient di teks panjang.
### 3.5 Warna Mapping Semantik (WAJIB konsisten)
**Status Siswa**
| Status | Warna | Badge |
|--------|-------|-------|
| `CALON_STUDENT` | Amber `#FFB020` | bg `#FFF4E0`, teks `#9A6100` |
| `STUDENT` | Mint `#12B886` | bg `#E4F8F1`, teks `#0A7A58` |
| `EX_STUDENT` | Slate `#6B7590` | bg `#EEF1F7`, teks `#4A5470` |
**Sumber Lead** (dipakai di bar chart & chip)
| Sumber | Warna | Ikon |
|--------|-------|------|
| `WHATSAPP` | `#22C55E` | `MessageCircle` |
| `REFERRAL` | `#7C5CFC` | `Users` |
| `BANNER` | `#FFB020` | `Flag` |
| `SOCIAL_MEDIA` | `#2AA9FF` | `Instagram` |
**Kehadiran**
| Status | Warna | Label |
|--------|-------|-------|
| `PRESENT` | Mint `#12B886` | Hadir |
| `LATE` | Amber `#FFB020` | Terlambat |
| `EXCUSED` | Sky `#2AA9FF` | Izin |
| `SICK` | Violet `#A78BFA` | Sakit |
| `ABSENT` | Danger `#F04438` | Alpa |
**Follow Up** : NEW `#94A3B8` · CONTACTED `#2AA9FF` · TRIAL `#7C5CFC` · OFFER_SENT `#FFB020` · ENROLLED `#12B886` · LOST `#F04438`
**Metode Bayar** : CASH `#12B886` · TRANSFER `#2AA9FF` · QRIS `#7C5CFC`
---
## 4. Typography
| Peran | Font | Weight | Size |
|-------|------|--------|------|
| Display / H1 | **Plus Jakarta Sans** | 800 | 3036px, tracking `-0.02em` |
| H2 | Plus Jakarta Sans | 700 | 24px |
| H3 / Card title | Plus Jakarta Sans | 600 | 18px |
| Body | **Inter** | 400/500 | 1415px, line-height 1.6 |
| Caption / label | Inter | 500 | 1213px, tracking `0.01em` |
| Angka dashboard | **Sora** | 700 | 2832px, tabular-nums |
| Kode / invoice | **JetBrains Mono** | 500 | 13px |
```
font-display : "Plus Jakarta Sans", Inter, system-ui, sans-serif
font-body : "Inter", system-ui, sans-serif
font-num : "Sora", Inter, sans-serif
font-mono : "JetBrains Mono", ui-monospace, monospace
```
Aturan: heading **selalu** gelap (`--ink-900`), jangan pakai warna brand untuk heading panjang. Warna brand untuk **aksen kata** (mis. "3 siswa **perlu follow up**").
---
## 5. Spacing · Radius · Shadow
- **Spacing scale:** 4 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48 · 64 (kelipatan 4).
- **Radius:** `sm 8px` (input kecil) · `md 12px` (button, input) · `lg 16px` (dropdown) · `xl 20px` (card) · `2xl 24px` (panel besar) · `full` (pill, avatar).
- **Shadow:**
```css
--shadow-xs: 0 1px 2px rgba(19,26,43,.06);
--shadow-sm: 0 2px 8px rgba(19,26,43,.06);
--shadow-md: 0 8px 24px rgba(19,26,43,.08);
--shadow-lg: 0 16px 40px rgba(19,26,43,.10);
--shadow-brand: 0 8px 20px rgba(124,92,252,.28); /* tombol primary */
```
- **Border:** 1px `--ink-100`; kartu aktif 1.5px `--brand-300`.
- **Focus ring:** `0 0 0 3px rgba(124,92,252,.28)`.
---
## 6. Iconography & Illustration
- **Ikon:** `lucide-react`, stroke 2, ukuran 18/20/24. Selalu dalam container bulat saat jadi aksi.
- **Ilustrasi/dekorasi:** bentuk blob, lingkaran, blok kode melayang — **opacity 612%**, gradient brand. Maksimal 12 elemen per halaman, jangan menghalangi konten.
- **Avatar:** bulat penuh, ukuran 32/40/56/96px, ring gradient `--grad-primary` untuk siswa aktif.
- **Emoji:** boleh untuk sapaan & empty state yang ramah, maksimal 1 per layar. Jangan di tombol/heading formal.
---
## 7. Layout & Navigasi
### 7.1 Shell Desktop (≥1280px) — 3 kolom
```
┌──────────────┬────────────────────────────────────────────┬───────────────────────┐
│ LEFT NAV │ MAIN CONTENT │ RIGHT RAIL │
│ 248px │ flex-1 (min 640px) │ 340px (sticky) │
│ │ │ │
│ ✦ Kodeva │ Topbar: breadcrumb · search · +Aksi · 🔔 │ ┌─────────────────┐ │
│ │ │ │ Siswa Aktif 128│ │
│ ▸ Dashboard │ ┌──────────────────────────────────────┐ │ ├─────────────────┤ │
│ ▸ Follow Up │ │ Konten halaman / tabel / kanban │ │ │ Perlu Follow 12 │ │
│ (12) 🔴 │ │ │ │ ├─────────────────┤ │
│ ▸ Siswa │ │ │ │ │ Ex Student 7 │ │
│ ▸ Kelas │ │ │ │ └─────────────────┘ │
│ ▸ Kehadiran │ └──────────────────────────────────────┘ │ ┌─────────────────┐ │
│ ▸ Pembayaran│ │ │ Kehadiran (donut)│ │
│ ▸ Jurnal │ │ └─────────────────┘ │
│ ▸ Raport │ │ ┌─────────────────┐ │
│ ▸ Placement │ │ │ Siswa Aktif (line)│ │
│ ▸ Pengaturan│ │ └─────────────────┘ │
│ │ │ ┌─────────────────┐ │
│ 👤 Sari W. │ │ │ Sumber Lead (bar)│ │
│ Admin │ │ └─────────────────┘ │
└──────────────┴────────────────────────────────────────────┴───────────────────────┘
```
- **Left nav:** background `--surface`, lebar 248px, item aktif = pill gradient brand dengan teks putih + `--shadow-brand`. Badge angka merah untuk follow up due. Dapat collapse jadi 72px (ikon saja).
- **Right rail ("Insight Panel"):** hanya muncul penuh di **Dashboard**; di halaman lain bisa dibuka lewat tombol toggle (`PanelRight`), default tersembunyi agar konten lega. Sticky, scroll independen, tiap widget = kartu `radius xl`.
- **Topbar:** tinggi 64px, sticky, blur backdrop `rgba(247,248,252,.8)`.
### 7.2 Breakpoint
| Breakpoint | Perilaku |
|-----------|----------|
| `≥1440px` | 3 kolom penuh, rail 340px |
| `12801439px` | 3 kolom, rail 300px |
| `10241279px` | rail jadi drawer kanan (default tutup) |
| `7681023px` | left nav collapse ikon; rail drawer |
| `<768px` | bottom nav 5 item utama; tabel → kartu; rail = sheet |
### 7.3 Bottom Nav (mobile)
`Dashboard · Siswa · Follow Up · Kelas · Lainnya`
---
## 8. Komponen
### 8.1 Tombol
| Varian | Style |
|--------|-------|
| Primary | `--grad-primary`, teks putih, radius md, `--shadow-brand`, hover naik 1px |
| Secondary | bg `--brand-50`, teks `--brand-700` |
| Outline | border `--ink-300`, teks `--ink-900` |
| Ghost | transparan, hover `--ink-100` |
| Danger | bg `#F04438`, teks putih |
| Pill/Chip | radius full, padding 6×12 |
Ukuran: `sm 32px` · `md 40px` · `lg 48px`. Sertakan ikon kiri untuk aksi utama.
### 8.2 Kartu
- **Stat Card:** ikon bulat gradient + label caption + angka besar (`font-num`) + delta (`+8%` mint / `-3%` coral). Tinggi 112px.
- **Content Card:** header (judul + aksi "Lihat semua") + body + padding 2024px.
- **Student Card:** avatar + nama + badge status + chip sumber + info kelas; hover shadow-md, cursor pointer.
- **Class Card:** strip warna kelas di kiri, nama, level chip, guru, `6/12 siswa` progress bar, jadwal.
### 8.3 Form
- Label di atas, 13px `--ink-700`, wajib ada `*` coral untuk required.
- Input tinggi 40px, radius md, border `--ink-300`, fokus ring brand.
- **Upload foto profil:** dropzone lingkaran dengan preview + tombol kamera; maks 5MB, JPG/PNG/WebP.
- Pilihan sumber lead & status: **segmented control** berikon (bukan select biasa) agar playful.
- Validasi inline: border danger + pesan 12px di bawah field.
### 8.4 Badge & Chip
- Status siswa pakai badge pill (dot + label).
- Sumber lead pakai chip berikon dengan warna mapping §3.5.
- Level kelas: Beginner (mint), Intermediate (sky), Advanced (violet).
### 8.5 Tabel
Header `--ink-100`, teks uppercase 12px `--ink-500`. Baris hover `--brand-50/50`, tinggi 56px. Kolom aksi sticky kanan. Di mobile berubah jadi kartu bertumpuk.
### 8.6 Kanban Follow Up
Kolom berisi header warna status + count + total value; kartu bisa drag (dnd-kit), sudut membulat, tampilkan avatar, sumber, "due 2 hari lalu" merah.
### 8.7 Empty · Loading · Error
- **Empty:** ilustrasi blob + judul ramah ("Belum ada calon siswa nih") + CTA.
- **Loading:** skeleton shimmer bentuk sama dengan konten, bukan spinner penuh layar.
- **Error:** kartu coral lembut + pesan jelas + tombol "Coba lagi".
---
## 9. Charts & Data Viz
Semua chart pakai **Recharts**, tinggi 180220px di rail, kartu putih, grid horizontal tipis `--ink-100`, tanpa garis sumbu Y penuh.
| Chart | Tipe | Warna | Isi |
|-------|------|-------|-----|
| **Siswa Aktif** | Area/donut mini | `--grad-primary` | Tren 6 bulan; angka besar di tengah |
| **Kehadiran** | Donut / radial | PRESENT mint, LATE amber, ABSENT danger | % kehadiran hari ini + ring |
| **Sumber Lead** | Bar horizontal | mapping §3.5 | Jumlah per WhatsApp/Referral/Banner/Social |
| **Siswa per Kelas** | Bar vertikal | `--violet-soft` | Kapasitas terpakai |
| **Pendapatan** | Line/area | `--mint-500` | Opsional di halaman pembayaran |
Aturan chart:
- Tooltip rounded, shadow-md, label Bahasa Indonesia.
- Angka `font-num`, animasi masuk 400ms ease-out.
- Selalu sediakan legend berwarna + label teks (jangan andalkan warna saja).
- Bar chart sumber lead: tampilkan angka di ujung bar.
### 9.1 Right Rail Widgets (Dashboard)
1. **Stat stack:** Siswa Aktif · Perlu Follow Up · Ex Student (3 kartu mini).
2. **Kehadiran Hari Ini:** donut + `86%` + rincian Hadir/Terlambat/Alpa.
3. **Tren Siswa Aktif:** area chart 6 bulan.
4. **Sumber Lead:** bar chart 4 kategori.
5. **Perlu Follow Up Hari Ini:** list 4 nama + tombol WhatsApp; link "Lihat semua".
6. **Kelas Hari Ini:** sesi + jam + guru.
---
## 10. Spesifikasi Halaman
### 10.1 Dashboard
Hero sapaan + 4 stat card besar (Siswa Aktif, Perlu Follow Up, Ex Student, Kehadiran %). Bawahnya: grafik Tren Siswa Aktif & Sumber Lead (2 kolom). Right rail = widget §9.1.
### 10.2 Halaman Siswa (list)
- Filter segmented: **Semua · Calon Student · Student · Ex Student**.
- Search nama/email/telepon, filter sumber lead, filter kelas.
- Toggle tampilan **tabel ⇄ kartu**.
- Tombol "+ Tambah Siswa" primary.
- Kolom: Siswa (avatar+nama+email) · Telepon · Orang Tua · Kelas · Sumber · Status · Aksi.
### 10.3 Form Tambah/Edit Siswa
Grid 2 kolom:
- Kiri: Foto profil (dropzone), Nama lengkap*, No. telepon*, Email, Nama orang tua, No. telepon orang tua, Tanggal lahir, Gender.
- Kanan: **Tahu dari mana*** (segmented: WhatsApp/Referral/Banner/Social Media), **Status*** (segmented: Calon/Student/Ex), **Pilih Kelas** (multi-select, hanya aktif bila status Student), Catatan.
- Sticky footer: Batal · Simpan.
### 10.4 Detail Siswa
Header profil: avatar besar ring gradient, nama, badge status, chip sumber, tombol WhatsApp/Call/Edit. Tab: **Ringkasan · Kelas & Kehadiran · Pembayaran · Placement Test · Raport · Jurnal**.
- **Placement Test** selalu tampil sebagai kartu: level hasil, skor per kategori (bar), rekomendasi kelas, tanggal, pemeriksa.
- **Pembayaran**: ringkasan prorata — `Kuota 10 sesi · Terpakai 6 · Sisa 4 · Nilai sisa Rp 800.000`.
### 10.5 Kelas (CRUD)
Grid kartu kelas dengan strip warna + progress kapasitas. Form: nama, kode, level, guru, total pertemuan, harga paket, kapasitas, jadwal, warna, status. Preview `Rp per pertemuan` otomatis saat mengisi harga & jumlah pertemuan.
### 10.6 Kehadiran
Pilih kelas → pilih pertemuan → daftar siswa dengan tombol status (segmented H/T/I/S/A) + tombol "Hadir Semua". Progress "12/12 terisi". Simpan.
### 10.7 Pembayaran
Form: pilih siswa → pilih kelas (auto ambil harga) → **jumlah pertemuan dibayar** (stepper) → tampil kalkulasi prorata besar: `10 × Rp 200.000 = Rp 2.000.000` → metode (Cash/Transfer/QRIS segmented) → tanggal → upload bukti (opsional). List pembayaran: invoice mono, siswa, kelas, sesi, nominal, metode chip, status.
### 10.8 Jurnal Aktivitas
Feed masonry/card: cover foto, judul, kelas, tanggal, tag, author, jumlah siswa. Tombol "+ Buat Jurnal": upload multi-foto (drag reorder), judul, deskripsi, kelas, sesi, tag, tandai siswa yang terlibat.
### 10.9 Raport
Pilih kelas → pilih siswa → form aspek penilaian (slider/input 0100): Logika, Computational Thinking, Problem Solving, Kreativitas, Kolaborasi → kesimpulan guru, grade otomatis, ringkasan kehadiran. Preview raport bertema (header gradient, tanda tangan guru).
### 10.10 Placement Test
Form: siswa, tanggal, pemeriksa, skor kategori (bar input), level hasil otomatis dari rata-rata + rekomendasi kelas. Riwayat list.
### 10.11 Pengaturan
Sumber lead (label + warna + aktif), daftar guru/user, info akademi (nama, logo, alamat, kontak), preferensi (mata uang, timezone).
---
## 11. Motion
| Aksi | Durasi | Easing |
|------|--------|--------|
| Hover kartu/tombol | 150ms | ease-out |
| Masuk halaman (fade+up 8px) | 250ms | cubic-bezier(.2,.8,.2,1) |
| Chart render | 400ms | ease-out |
| Drawer/sheet | 240ms | cubic-bezier(.2,.8,.2,1) |
| Konfeti saat siswa enroll 🎉 | 800ms | sekali, opsional |
Hormati `prefers-reduced-motion`: matikan animasi non-esensial.
---
## 12. Aksesibilitas
- Kontras teks minimal **AA** (4.5:1 body, 3:1 besar). Jangan taruh teks putih di amber/kuning.
- Fokus terlihat di semua elemen interaktif (ring brand).
- Jangan sampaikan status **hanya** lewat warna — selalu ada label/ikon.
- Semua input punya `<label>`; ikon-only button punya `aria-label`.
- Target sentuh ≥ 44×44px di mobile.
- Chart punya ringkasan teks/table alternatif.
---
## 13. Design Tokens (CSS)
```css
:root {
/* brand */
--brand-50:#F2EFFE; --brand-100:#E4DEFD; --brand-300:#B9A9FB;
--brand-500:#7C5CFC; --brand-600:#6A46EF; --brand-700:#5533CE;
/* accent */
--coral-500:#FF7A59; --amber-500:#FFB020; --mint-500:#12B886;
--sky-500:#2AA9FF; --pink-500:#F65FA7; --danger-500:#F04438; --violet-soft:#A78BFA;
/* neutral */
--ink-900:#131A2B; --ink-700:#3A4560; --ink-500:#6B7590;
--ink-300:#C4CAD8; --ink-100:#EEF1F7; --surface:#FFFFFF; --canvas:#F7F8FC;
/* gradients */
--grad-primary:linear-gradient(135deg,#7C5CFC 0%,#2AA9FF 100%);
--grad-coral:linear-gradient(135deg,#FF7A59 0%,#FFB020 100%);
--grad-mint:linear-gradient(135deg,#12B886 0%,#2AA9FF 100%);
--grad-pink:linear-gradient(135deg,#F65FA7 0%,#7C5CFC 100%);
/* radius */
--r-sm:8px; --r-md:12px; --r-lg:16px; --r-xl:20px; --r-2xl:24px; --r-full:9999px;
/* shadow */
--shadow-xs:0 1px 2px rgba(19,26,43,.06);
--shadow-sm:0 2px 8px rgba(19,26,43,.06);
--shadow-md:0 8px 24px rgba(19,26,43,.08);
--shadow-lg:0 16px 40px rgba(19,26,43,.10);
--shadow-brand:0 8px 20px rgba(124,92,252,.28);
/* font */
--font-display:"Plus Jakarta Sans",Inter,system-ui,sans-serif;
--font-body:"Inter",system-ui,sans-serif;
--font-num:"Sora",Inter,sans-serif;
--font-mono:"JetBrains Mono",ui-monospace,monospace;
/* layout */
--nav-w:248px; --rail-w:340px; --topbar-h:64px;
}
```
---
## 14. Contoh Konten & Data untuk UI
**Data grafik right rail (contoh):**
```json
{
"activeStudents": 128,
"activeStudentsDelta": "+8%",
"followUpDue": 12,
"exStudents": 7,
"attendanceToday": { "present": 34, "late": 4, "absent": 3, "rate": 86 },
"activeTrend": [
{ "month": "Apr", "value": 92 }, { "month": "Mei", "value": 101 },
{ "month": "Jun", "value": 97 }, { "month": "Jul", "value": 110 },
{ "month": "Agu", "value": 119 }, { "month": "Sep", "value": 128 }
],
"leadSources": [
{ "source": "WhatsApp", "value": 58 },
{ "source": "Referral", "value": 34 },
{ "source": "Banner", "value": 21 },
{ "source": "Social Media", "value": 15 }
]
}
```
**Kelas:**
| Kelas | Level | Guru | Sesi | Harga | Rp/Sesi | Warna |
|-------|-------|------|:----:|-------|--------:|-------|
| Scratch Junior | Beginner | Rina Kartika | 8 | Rp 1.200.000 | Rp 150.000 | `#2AA9FF` |
| Python Dasar | Beginner | Budi Santoso | 10 | Rp 2.000.000 | Rp 200.000 | `#7C5CFC` |
| Web Dev: HTML & CSS | Beginner | Ahmad Fauzi | 12 | Rp 2.400.000 | Rp 200.000 | `#12B886` |
| JavaScript Intermediate | Intermediate | Budi Santoso | 10 | Rp 2.500.000 | Rp 250.000 | `#FFB020` |
| Arduino & Robotics | Intermediate | Rina Kartika | 8 | Rp 1.800.000 | Rp 225.000 | `#F65FA7` |
**Siswa (contoh tampilan):**
| Nama | Orang Tua | Sumber | Status | Kelas |
|------|-----------|--------|--------|-------|
| Andi Pratama | Budi Pratama | Referral | Student | Python Dasar |
| Citra Lestari | Sari Lestari | WhatsApp | Calon Student | — |
| Dimas Anggara | Hendra Anggara | Banner | Student | Scratch Junior |
| Elsa Maharani | Rina Maharani | Social Media | Calon Student | — |
| Fajar Nugroho | Agus Nugroho | WhatsApp | Student | Web Dev |
| Gita Permata | Dwi Permata | Referral | Ex Student | JavaScript |
| Hadi Susanto | Tono Susanto | Social Media | Student | Arduino |
| Intan Puspita | Maya Puspita | Banner | Calon Student | — |
| Joko Wijaya | Slamet Wijaya | WhatsApp | Student | Python Dasar |
| Kirana Dewi | Ratna Dewi | Referral | Ex Student | Scratch Junior |
**Jurnal contoh:** "Membuat Game Ular dengan Scratch" · Scratch Junior · tag `game, scratch, logika` · 6 siswa · 4 foto.
**Raport contoh:** Andi Pratama · Python Dasar · Logika 88 · Computational Thinking 85 · Problem Solving 82 · Kreativitas 90 · Kolaborasi 86 · Grade **A**.
**Placement contoh:** Elsa Maharani · total 78 · Level **Intermediate** · rekomendasi **JavaScript Intermediate**.
**Microcopy (nada santai-profesional):**
- Empty follow up: "Yeay, tidak ada follow up yang telat hari ini 🎉"
- Tombol: "Tambah Siswa", "Catat Pembayaran", "Simpan Jurnal", "Publish Raport"
- Konfirmasi prorata: "Siswa membayar **Rp 2.000.000** untuk **10 pertemuan** (Rp 200.000/pertemuan). Sisa kuota saat ini **4 pertemuan**."
---
## 15. Checklist Desain untuk Setiap Halaman
- [ ] Pakai font & token warna dari dokumen ini (tidak ada hex baru).
- [ ] Ada state loading (skeleton), empty, error.
- [ ] Responsif 3 breakpoint besar (desktop / tablet / mobile).
- [ ] Kontras AA & fokus terlihat.
- [ ] Angka uang pakai `formatIDR` + `font-num`.
- [ ] Status/lead memakai warna mapping §3.5 + label teks.
- [ ] Right rail tersedia di Dashboard, toggle-able di halaman lain.
- [ ] Micro-animation halus & hormati `prefers-reduced-motion`.
+360
View File
@@ -0,0 +1,360 @@
# PLAN.md — Rencana Pengembangan Kodeva
> Rencana eksekusi untuk membangun **Kodeva** (Sistem Manajemen Kursus Coding).
> Dokumen pendamping: [`AGENTS.md`](./AGENTS.md) (engineering), [`DESIGN.md`](./DESIGN.md) (UI/UX), [`seed.example.ts`](./seed.example.ts) (contoh data).
> Status: **Draft v1.0** · Estimasi total **810 minggu** (1 full-stack dev + AI agent).
---
## 1. Tujuan & Kriteria Sukses
**Tujuan:** Aplikasi web internal untuk mengelola siklus siswa kursus coding — dari calon siswa, follow up, enrollment, kehadiran, pembayaran prorata, jurnal, raport, hingga placement test.
**Kriteria sukses (v1.0):**
1. Admin bisa CRUD siswa + follow up dan mengubah status `CALON_STUDENT → STUDENT → EX_STUDENT` tanpa Excel.
2. Dashboard menampilkan angka **perlu follow up** & **ex student** serta 3 grafik (siswa aktif, kehadiran, sumber lead) di right rail.
3. Pembayaran prorata per pertemuan (contoh Rp 2.000.000/10 → Rp 200.000/pertemuan) dengan metode Cash/Transfer/QRIS.
4. Guru bisa input kehadiran, jurnal+foto, dan raport per siswa.
5. Kelas dapat dikonfigurasi (nama, level, guru, jumlah pertemuan, harga, kapasitas, jadwal).
6. Hasil placement test tampil di detail siswa.
7. Semua role & data terlindungi RBAC; tidak ada payment gateway.
8. Lulus e2e flow utama + `lint`/`typecheck`/`test` hijau.
---
## 2. Ruang Lingkup
### 2.1 In Scope (v1.0)
- Auth & RBAC (Admin, Teacher)
- Shell layout: left nav + main + right rail
- CRUD Siswa (field lengkap + foto profil + status + sumber lead)
- Follow Up pipeline (kanban) + aktivitas + jadwal due
- CRUD Kelas + Enrollment + generate Session
- Kehadiran per pertemuan (bulk)
- Pembayaran prorata + metode + bukti
- Jurnal aktivitas + upload multi-foto
- Raport (aspek penilaian + publish)
- Placement Test (skor kategori + level + rekomendasi)
- Dashboard + grafik right rail
- Seed contoh data
### 2.2 Out of Scope (ditunda)
- Payment gateway / auto-reconciliation
- Notifikasi WhatsApp otomatis (v1.1, integrasi API)
- Portal/login untuk orang tua & siswa
- Multi-cabang / multi-tenant
- Mobile app native
- Absensi QR / face recognition
- Sertifikat otomatis (v1.1)
- Akuntansi/laporan keuangan lengkap
---
## 3. Asumsi, Tim & Kapasitas
| Item | Asumsi |
|------|--------|
| Tim | 1 full-stack developer + AI agent (pair) |
| Desainer | 1 part-time (audit visual di M1 & M7) |
| Stack | Sesuai AGENTS.md §2 (Next.js 15 + Prisma + Postgres) |
| Kapasitas | ~5 hari kerja/minggu, ~6 jam efektif/hari |
| Lingkungan | Dev lokal + 1 staging (Vercel + Neon/Supabase) |
| Bahasa | UI Bahasa Indonesia |
| Data | Mulai dari `seed.example.ts`, direset tiap sprint |
**Definition of Ready (DoR):** task punya deskripsi, acceptance criteria, dan file/lokasi yang dituju.
**Definition of Done (DoD):** kode + test + lint/typecheck hijau, UI sesuai DESIGN.md, 4 state (loading/empty/error/success), sudah di-review agent/manusia.
---
## 4. Ringkasan Timeline
```
Minggu 1 2 3 4 5 6 7 8 9 10
M0 Setup ██
M1 Fondasi ████
M2 Siswa & ████
Follow Up
M3 Kelas & ████
Kehadiran
M4 Pembayaran ████
M5 Jurnal ████
M6 Raport & ████
Placement
M7 Dashboard & ████
Right Rail
M8 Hardening ████
M9 UAT & Launch ████
```
| Milestone | Nama | Estimasi | Dependency | Requirement |
|-----------|------|:--------:|------------|:-----------:|
| M0 | Setup & Scaffolding | 34 hari | — | — |
| M1 | Fondasi: Auth + Shell + Schema + Seed | 1 minggu | M0 | — |
| M2 | Siswa & Follow Up | 1,5 minggu | M1 | #1, #7 |
| M3 | Kelas, Enrollment & Kehadiran | 1,5 minggu | M1, M2 | #5, #4 |
| M4 | Pembayaran Prorata | 1 minggu | M3 | #3 |
| M5 | Jurnal Aktivitas + Upload Foto | 1 minggu | M3 | #6 |
| M6 | Raport & Placement Test | 1 minggu | M3 | #4, #8 |
| M7 | Dashboard & Right Rail | 1 minggu | M2M6 | #2, #7 |
| M8 | Hardening, QA & Polish | 1 minggu | M0M7 | — |
| M9 | UAT & Launch | 1 minggu | M8 | semua |
---
## 5. Detail Per Milestone
### M0 — Setup & Scaffolding (34 hari)
**Goal:** Repo siap dibangun, konvensi & CI berjalan.
**Tasks**
- [ ] Init Next.js 15 (App Router, TS strict) + pnpm + folder sesuai AGENTS.md §4
- [ ] Tailwind v4 + CSS tokens DESIGN.md §13 + font (Plus Jakarta Sans, Inter, Sora, JetBrains Mono)
- [ ] shadcn/ui + lucide-react + Recharts + TanStack Table + dnd-kit
- [ ] ESLint + Prettier + Husky + lint-staged
- [ ] `.env.example`, `lib/utils.ts` (`cn`, `formatIDR`, `formatDateID`)
- [ ] Prisma init + Postgres lokal (Docker) + script `db:push`/`db:seed`
- [ ] CI dasar (lint + typecheck + test)
- [ ] Halaman `/health` verifikasi deploy
**Deliverable:** `pnpm dev` jalan, tokens tampil di halaman contoh, CI hijau.
**Acceptance:** `pnpm lint && pnpm typecheck && pnpm test` hijau.
---
### M1 — Fondasi: Auth + Shell + Schema + Seed (1 minggu)
**Goal:** Shell 3 kolom + login + database + data contoh.
**Tasks**
- [ ] Finalisasi `prisma/schema.prisma` (AGENTS.md §6) + `db:push`
- [ ] Salin `seed.example.ts``prisma/seed.ts`, verifikasi output seed
- [ ] Auth.js v5 Credentials + session + `hashPassword`/`verify`
- [ ] Middleware proteksi route + `withAuth` + `src/lib/rbac.ts`
- [ ] **AppShell**: `AppSidebar` (248px, item aktif pill gradient, badge follow up), `Topbar` (breadcrumb, search, aksi, notif, avatar), `RightRail` (sticky, toggle)
- [ ] Komponen UI dasar: Button, Card, StatCard, Badge, Chip, Input, Segmented, Dropdown, Modal, Skeleton, EmptyState
- [ ] Halaman `/login`, redirect per role
- [ ] Audit visual desainer (palet, kontras, spacing)
**Deliverable:** Login berfungsi, shell responsif 3 breakpoint, halaman kosong per menu.
**Acceptance:** login admin/teacher sukses; route terproteksi menolak tanpa sesi; nav highlight benar; skeleton tampil.
---
### M2 — Siswa & Follow Up (1,5 minggu) → Req #1, #7
**Goal:** CRUD siswa lengkap + pipeline follow up.
**Tasks**
- [ ] `lib/validators/student.ts` (Zod) + `followUp.ts`
- [ ] API: `/api/students` (GET/POST), `/api/students/[id]` (GET/PATCH/DELETE/archive)
- [ ] Halaman `/students`: tabel + filter status/sumber/kelas + search + toggle tabel⇄kartu
- [ ] Form tambah/edit: foto (dropzone), nama, telepon, email, orang tua, sumber lead (segmented), status (segmented), multi-kelas
- [ ] Detail siswa: header profil + tab Ringkasan / Kelas / Pembayaran / Placement / Raport / Jurnal (tab diisi bertahap)
- [ ] `StudentCard`, `StatusBadge`, `SourceChip`
- [ ] Follow Up: `/follow-up` kanban (dnd-kit) 6 kolom + drag ubah status
- [ ] `FollowUpActivity` timeline + set `nextFollowUpAt` + PIC
- [ ] `lib/follow-up.ts`: query due (`status ∉ ENROLLED/LOST && nextFollowUpAt ≤ endOfToday`)
- [ ] Aksi "Enroll": ubah status → STUDENT, buat `Enrollment` (validasi kapasitas), arahkan ke pembayaran
- [ ] Tombol WhatsApp/Call (deep link `wa.me`, `tel:`)
**Acceptance:**
- CRUD siswa bertahan setelah refresh; foto terupload.
- Kanban drag mengubah status & tersimpan.
- `followUpDue` benar sesuai seed (2 due).
- Status siswa berubah otomatis saat enroll.
- Unit test `follow-up.ts` hijau.
---
### M3 — Kelas, Enrollment & Kehadiran (1,5 minggu) → Req #5, #4
**Goal:** Konfigurasi kelas + pertemuan + absensi.
**Tasks**
- [ ] API `/api/classes` (CRUD) + `/api/classes/[id]/sessions`
- [ ] Halaman `/classes`: grid kartu (strip warna, progress kapasitas, jadwal)
- [ ] Form kelas: nama, kode unik, level, guru, `totalMeetings`, `packagePrice`, `pricePerMeeting` (auto-preview `Rp/pertemuan`), kapasitas, jadwal, warna, status
- [ ] Detail kelas: roster siswa, daftar sesi, tab jurnal
- [ ] Generate `Session` otomatis (weekly) saat kelas diaktifkan; opsi edit/hapus
- [ ] API `/api/attendance` (GET per sesi, bulk upsert)
- [ ] Halaman `/attendance`: pilih kelas → pertemuan → grid siswa + segmented H/T/I/S/A + "Hadir Semua" + progress `12/12 terisi`
- [ ] `lib/attendance.ts`: `attendanceRate`
- [ ] Unit test validasi kapasitas & generate sesi
**Acceptance:**
- Kelas baru muncul & bisa dipilih di form siswa.
- Siswa ke-13 ditolak saat kapasitas 12 (pesan jelas).
- Kehadiran tersimpan per sesi & bisa diedit; rate benar.
---
### M4 — Pembayaran Prorata (1 minggu) → Req #3
**Goal:** Input pembayaran per pertemuan + rekap.
**Tasks**
- [ ] `lib/prorata.ts` (PURE): `pricePerMeeting`, `amount`, `usedMeetings`, `remainingMeetings`, `proratedValue`, `deriveStatus`
- [ ] Unit test lengkap (batas: `totalMeetings=0`, bayar > total, parsial, kehadiran > kuota)
- [ ] API `/api/payments` (GET/POST), `/api/payments/[id]` (PATCH/refund)
- [ ] Halaman `/payments`: tabel invoice (mono), filter siswa/kelas/metode/status
- [ ] Form: pilih siswa → kelas (auto harga) → stepper `meetingsPaid`**kalkulasi prorata live** → metode (Cash/Transfer/QRIS) → tanggal → upload bukti
- [ ] Nomor invoice otomatis (`INV-YYYY-NNNN`)
- [ ] Kartu ringkasan di detail siswa: `Kuota 10 · Terpakai 6 · Sisa 4 · Nilai sisa Rp 800.000`
- [ ] Badge "Perlu Perpanjang" bila sesi terpakai ≥ kuota
**Acceptance:**
- Input 10 pertemuan × Rp 200.000 = Rp 2.000.000.
- Sisa kuota & nilai sisa terhitung benar dari kehadiran.
- Metode tampil sebagai chip berwarna (Cash mint, Transfer sky, QRIS violet).
- Semua unit test prorata hijau.
---
### M5 — Jurnal Aktivitas + Upload Foto (1 minggu) → Req #6
**Goal:** Feed aktivitas kelas dengan dokumentasi foto.
**Tasks**
- [ ] API `/api/uploads` (validasi tipe & ukuran, simpan ke `public/uploads` / S3)
- [ ] API `/api/journals` (GET feed/POST), `/api/journals/[id]` (GET/PATCH/DELETE)
- [ ] Halaman `/journals`: feed kartu (cover foto, judul, kelas, tanggal, tag, author, jumlah siswa)
- [ ] Form jurnal: multi-foto drag-reorder, judul, deskripsi, kelas, sesi, tag, tandai siswa terlibat
- [ ] Lightbox galeri foto
- [ ] Jurnal tampil di detail siswa (tab Jurnal) & detail kelas
- [ ] Empty/loading state sesuai DESIGN.md §8.7
**Acceptance:**
- Upload 4 foto berhasil, urutan tersimpan, tampil di feed & detail.
- Validasi tolak file > 5MB / non-gambar.
- Jurnal terkait siswa yang benar.
---
### M6 — Raport & Placement Test (1 minggu) → Req #4, #8
**Goal:** Penilaian siswa + hasil tes penempatan.
**Tasks**
- [ ] API `/api/report-cards` (GET/POST), `.../[id]/publish`
- [ ] Form raport: aspek (Logika, Computational Thinking, Problem Solving, Kreativitas, Kolaborasi) 0100 → rata-rata, grade otomatis A/B/C/D, catatan guru, ringkasan kehadiran
- [ ] Preview raport bertema (header gradient, tanda tangan guru) + tombol Publish
- [ ] Halaman `/report-cards` (list per kelas/periode)
- [ ] API `/api/placements` (GET/POST)
- [ ] Form placement: siswa, tanggal, pemeriksa, skor kategori → level otomatis + rekomendasi kelas
- [ ] **Kartu Placement Test di detail siswa**: level, skor per kategori (bar), rekomendasi, tanggal, pemeriksa, badge di header
- [ ] Unit test perhitungan grade & level
**Acceptance:**
- Raport Andi (seed) tampil dengan grade benar & bisa dipublish.
- Placement Elsa tampil `Intermediate` + rekomendasi `JavaScript Intermediate`.
- Badge level muncul di header profil siswa.
---
### M7 — Dashboard & Right Rail (1 minggu) → Req #2, #7
**Goal:** Semua grafik & ringkasan.
**Tasks**
- [ ] API `/api/dashboard/summary` (paralel: counts + trend + leadSources + attendance)
- [ ] Hero sapaan + 4 StatCard besar (Siswa Aktif, Perlu Follow Up, Ex Student, Kehadiran %)
- [ ] Right rail widgets (DESIGN.md §9.1): stat stack, donut kehadiran, area tren siswa, bar sumber lead, list follow up hari ini, kelas hari ini
- [ ] Chart components (Recharts): `ActiveStudentsChart`, `AttendanceDonut`, `LeadSourceBar` + legend & tooltip Bahasa Indonesia
- [ ] Warna chart sesuai mapping DESIGN.md §3.5
- [ ] Responsif: rail jadi drawer < 1280px
- [ ] Animasi masuk 250ms + chart 400ms, hormati `prefers-reduced-motion`
**Acceptance:**
- Angka dashboard cocok dengan seed (4 aktif, 2 ex, 2 due).
- Bar chart sumber lead menampilkan WhatsApp/Referral/Banner/Social dengan angka.
- Rail tetap fungsional di tablet (drawer).
---
### M8 — Hardening, QA & Polish (1 minggu)
**Goal:** Stabil, aman, cepat, aksesibel.
**Tasks**
- [ ] Audit RBAC semua route + test unauthorized
- [ ] Validasi Zod di semua boundary; sanitasi upload
- [ ] Optimasi query (index, hindari N+1) + `loading.tsx`/Suspense
- [ ] Aksesibilitas: fokus, label, kontras AA, target sentuh 44px
- [ ] Audit visual final (desainer) + konsistensi token
- [ ] Error boundary + toast + halaman 404/500
- [ ] E2E Playwright flow utama
- [ ] Dokumentasi singkat (README: setup & peran)
**Acceptance:**
- `pnpm lint && typecheck && test && test:e2e` hijau.
- Lighthouse mobile ≥ 90 performance/accessibility untuk dashboard.
- Tidak ada error di console.
---
### M9 — UAT & Launch (1 minggu)
**Goal:** Rilis ke pengguna nyata.
**Tasks**
- [ ] Deploy staging + migrasi data contoh
- [ ] Sesi UAT dengan admin & 1 guru (skenario tasks)
- [ ] Perbaiki temuan blocker/high
- [ ] Backup & restore DB terdokumentasi
- [ ] Deploy production + domain + monitoring error (Sentry)
- [ ] Pelatihan singkat + panduan penggunaan
- [ ] Retrospective & susun backlog v1.1
**Acceptance:** Admin & guru menyelesaikan 8 skenario UAT tanpa bantuan; data produksi aman.
---
## 6. Backlog Prioritas (MoSCoW)
**Must (v1.0):** semua requirement #1#8, auth/RBAC, seed, deploy.
**Should:** dark mode, export CSV siswa/pembayaran, filter lanjutan, audit log, konfeti enroll.
**Could:** notifikasi WhatsApp otomatis, sertifikat PDF, portal ortu, absensi QR, laporan pendapatan.
**Won't (kini):** payment gateway, multi-cabang, mobile native.
---
## 7. Risiko & Mitigasi
| Risiko | Dampak | Prob. | Mitigasi |
|--------|:------:|:-----:|----------|
| Scope melebar (fitur baru saat coding) | Tinggi | Sedang | Kunci v1.0 ke Must; fitur baru masuk backlog v1.1 |
| Logika prorata ambigu (refund/rollover) | Tinggi | Sedang | Kunci aturan di AGENTS.md §7.1 + unit test sebelum UI |
| Desain tidak konsisten antar halaman | Sedang | Sedang | Wajib pakai token DESIGN.md; audit di M1 & M8 |
| Upload foto besar bikin lambat/biaya | Sedang | Sedang | Batasi 5MB, kompres (sharp), lazy-load |
| Kapasitas kelas & enrollment bentrok | Sedang | Rendah | Validasi server-side + test |
| Database drift schema | Sedang | Rendah | Migrasi Prisma, staging sebelum prod |
| Estimasi molor karena 1 dev | Sedang | Sedang | Prioritaskan Must tiap sprint; cut Should bila perlu |
---
## 8. Tracking & Ritual
- **Papan:** GitHub Projects / Linear — kolom `Backlog · Ready · In Progress · Review · Done`.
- **Sprint:** 1 minggu, demo di akhir sprint (per milestone).
- **Definition of Ready/Done:** §3.
- **Branch:** `feat/…`, `fix/…`; PR wajib lulus CI + review.
- **Commit:** Conventional Commits.
---
## 9. Setelah v1.0 (v1.1+)
1. Notifikasi WhatsApp otomatis (reminder follow up, jadwal, tagihan).
2. Export laporan (CSV/PDF) & sertifikat siswa.
3. Portal orang tua/siswa (lihat raport, jadwal, tagihan).
4. Absensi QR + rekap otomatis.
5. Laporan pendapatan & aging piutang.
6. Multi-cabang + dashboard per cabang.
7. Dark mode & PWA.
---
## 10. Checklist Ringkas Per Requirement
| Req | Fitur | Milestone | Status |
|:---:|-------|:---------:|:------:|
| 1 | CRUD follow up siswa baru (field lengkap + status) | M2 | ⬜ |
| 2 | Right rail dashboard (siswa aktif, kehadiran, bar sumber lead) | M7 | ⬜ |
| 3 | Pembayaran prorata + Cash/Transfer/QRIS | M4 | ⬜ |
| 4 | Guru input jurnal, raport, kehadiran | M3, M5, M6 | ⬜ |
| 5 | Konfigurasi kelas + pilih kelas saat input siswa | M3 | ⬜ |
| 6 | Jurnal aktivitas + upload foto | M5 | ⬜ |
| 7 | Dashboard: perlu follow up & ex student | M2, M7 | ⬜ |
| 8 | Placement test tampil di info siswa | M6 | ⬜ |
+34
View File
@@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+48
View File
@@ -0,0 +1,48 @@
{
"name": "pi-cwd-20260918",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:reset": "prisma db push --force-reset && prisma db seed",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"jose": "^6.2.12",
"lucide-react": "^1.47.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"tailwind-merge": "^3.7.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"prisma": "^6.19.3",
"tailwindcss": "^4",
"tsx": "^4.23.13",
"typescript": "^5"
},
"packageManager": "pnpm@12.4.2"
}
+5291
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true
sharp: false
unrs-resolver: false
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+281
View File
@@ -0,0 +1,281 @@
// Kodeva — Prisma schema
// DEV: SQLite (tanpa Docker/Postgres). Untuk PRODUKSI ganti provider ke "postgresql"
// dan ubah String enum/list kembali menjadi enum + String[] (lihat AGENTS.md §6).
//
// Nilai enum disimpan sebagai String agar kompatibel SQLite. Konstanta TypeScript
// tersedia di src/lib/constants.ts.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
role String @default("TEACHER") // ADMIN | 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 String? // MALE | FEMALE
address String?
leadSource String // WHATSAPP | REFERRAL | BANNER | SOCIAL_MEDIA
status String @default("CALON_STUDENT") // CALON_STUDENT | STUDENT | EX_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[]
@@index([status])
@@index([leadSource])
}
model Class {
id String @id @default(cuid())
name String
code String @unique
description String?
level String // BEGINNER | INTERMEDIATE | ADVANCED
teacherId String?
teacher User? @relation("ClassTeacher", fields: [teacherId], references: [id])
totalMeetings Int @default(10)
packagePrice Int @default(0)
pricePerMeeting Int?
capacity Int @default(12)
schedule String?
startDate DateTime?
endDate DateTime?
status String @default("ACTIVE") // DRAFT | ACTIVE | COMPLETED | ARCHIVED
color String?
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 String @default("ACTIVE") // ACTIVE | COMPLETED | DROPPED
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 String // PRESENT | LATE | EXCUSED | SICK | ABSENT
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
method String // CASH | TRANSFER | QRIS
status String @default("PAID") // UNPAID | PARTIAL | PAID | REFUNDED
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 @default("") // comma separated
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
photos JournalPhoto[]
students JournalStudent[]
}
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
teacherId String
teacher User @relation(fields: [teacherId], references: [id])
attendanceSummary String?
finalScore Int?
grade String?
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
score Int
note String?
}
model PlacementTest {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
testDate DateTime
levelResult String // BEGINNER | INTERMEDIATE | ADVANCED
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
score Int
}
model FollowUp {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
status String @default("NEW") // NEW | CONTACTED | TRIAL | OFFER_SENT | ENROLLED | LOST
picId String?
pic User? @relation("FollowUpPIC", fields: [picId], references: [id])
nextFollowUpAt DateTime?
lastContactedAt DateTime?
channel String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities FollowUpActivity[]
@@index([status])
@@index([nextFollowUpAt])
}
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 String // JSON string (SQLite)
}
+329
View File
@@ -0,0 +1,329 @@
/**
* seed.example.ts — Contoh data Kodeva (Sistem Manajemen Kursus Coding)
* ---------------------------------------------------------------------
* Cara pakai:
* 1) Salin ke `prisma/seed.ts`
* 2) Tambahkan ke package.json → "prisma": { "seed": "tsx prisma/seed.ts" }
* 3) Jalankan: pnpm db:push && pnpm db:seed
*
* Login contoh:
* admin@kodeva.id / password123 (ADMIN)
* budi@kodeva.id / password123 (TEACHER)
* rina@kodeva.id / password123 (TEACHER)
*
* Catatan: cocok dengan skema di AGENTS.md (PostgreSQL).
*/
import { PrismaClient, AttendanceStatus, PaymentStatus, PlacementLevel } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const BASE = new Date("2026-09-18T00:00:00.000Z");
const addDays = (n: number, from: Date = BASE) => new Date(from.getTime() + n * 86_400_000);
const addWeeks = (n: number, from: Date = BASE) => addDays(n * 7, from);
const PASS = bcrypt.hashSync("password123", 10);
const avatar = (seed: string) => `https://i.pravatar.cc/240?u=${encodeURIComponent(seed)}`;
const photo = (seed: string) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/900/600`;
async function main() {
console.log("🌱 Seed Kodeva dimulai...");
// Bersihkan (urutan penting karena relasi)
await prisma.followUpActivity.deleteMany();
await prisma.followUp.deleteMany();
await prisma.placementScore.deleteMany();
await prisma.placementTest.deleteMany();
await prisma.reportScore.deleteMany();
await prisma.reportCard.deleteMany();
await prisma.journalStudent.deleteMany();
await prisma.journalPhoto.deleteMany();
await prisma.journal.deleteMany();
await prisma.payment.deleteMany();
await prisma.attendance.deleteMany();
await prisma.session.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.student.deleteMany();
await prisma.class.deleteMany();
await prisma.user.deleteMany();
await prisma.setting.deleteMany();
// ─────────────────────────────────────────────────────────────
// 1. USERS (Admin + Teacher)
// ─────────────────────────────────────────────────────────────
const sari = await prisma.user.create({
data: {
name: "Sari Wulandari", email: "admin@kodeva.id", passwordHash: PASS,
role: "ADMIN", phone: "0811-2000-100", avatarUrl: avatar("sari"),
},
});
const budi = await prisma.user.create({
data: {
name: "Budi Santoso", email: "budi@kodeva.id", passwordHash: PASS,
role: "TEACHER", phone: "0812-3000-200", avatarUrl: avatar("budi"),
},
});
const rina = await prisma.user.create({
data: {
name: "Rina Kartika", email: "rina@kodeva.id", passwordHash: PASS,
role: "TEACHER", phone: "0813-4000-300", avatarUrl: avatar("rina"),
},
});
const ahmad = await prisma.user.create({
data: {
name: "Ahmad Fauzi", email: "ahmad@kodeva.id", passwordHash: PASS,
role: "TEACHER", phone: "0814-5000-400", avatarUrl: avatar("ahmad"),
},
});
// ─────────────────────────────────────────────────────────────
// 2. KELAS (konfigurasi kelas)
// pricePerMeeting = packagePrice / totalMeetings
// ─────────────────────────────────────────────────────────────
const classesData = [
{ code: "KOD-SCR-01", name: "Scratch Junior", level: "BEGINNER" as PlacementLevel, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_200_000, pricePerMeeting: 150_000, capacity: 10, schedule: "Senin & Rabu 16:00-17:30", color: "#2AA9FF", description: "Belajar logika pemrograman lewat game visual Scratch." },
{ code: "KOD-PYT-01", name: "Python Dasar", level: "BEGINNER" as PlacementLevel, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_000_000, pricePerMeeting: 200_000, capacity: 12, schedule: "Selasa & Kamis 17:00-18:30", color: "#7C5CFC", description: "Fondasi Python: variabel, logika, loop, dan fungsi." },
{ code: "KOD-WEB-01", name: "Web Dev: HTML & CSS", level: "BEGINNER" as PlacementLevel, teacherId: ahmad.id, totalMeetings: 12, packagePrice: 2_400_000, pricePerMeeting: 200_000, capacity: 14, schedule: "Sabtu 09:00-12:00", color: "#12B886", description: "Membangun website pertamamu dari nol." },
{ code: "KOD-JS-01", name: "JavaScript Intermediate", level: "INTERMEDIATE" as PlacementLevel, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_500_000, pricePerMeeting: 250_000, capacity: 12, schedule: "Selasa & Kamis 18:45-20:15", color: "#FFB020", description: "DOM, event, async, dan mini project interaktif." },
{ code: "KOD-ARD-01", name: "Arduino & Robotics", level: "INTERMEDIATE" as PlacementLevel, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_800_000, pricePerMeeting: 225_000, capacity: 8, schedule: "Jumat 15:30-17:30", color: "#F65FA7", description: "Rakit dan program robot pertamamu dengan Arduino." },
];
const klass = Object.fromEntries(
await Promise.all(
classesData.map(async (c) => [c.code, await prisma.class.create({ data: { ...c, startDate: addWeeks(-10), status: "ACTIVE" } })]),
),
) as Record<string, Awaited<ReturnType<typeof prisma.class.create>>>;
// ─────────────────────────────────────────────────────────────
// 3. SISWA (10 orang: 4 calon, 4 student, 2 ex student)
// Field: nama, telepon, email, foto, orang tua, sumber lead, status
// ─────────────────────────────────────────────────────────────
const studentsSeed = [
{ key: "andi", fullName: "Andi Pratama", phone: "0812-1111-0001", email: "andi@example.com", parentName: "Budi Pratama", parentPhone: "0812-1111-9001", leadSource: "REFERRAL" as const, status: "STUDENT" as const, joinedAt: addWeeks(-10) },
{ key: "citra", fullName: "Citra Lestari", phone: "0812-1111-0002", email: "citra@example.com", parentName: "Sari Lestari", parentPhone: "0812-1111-9002", leadSource: "WHATSAPP" as const, status: "CALON_STUDENT" as const, joinedAt: null },
{ key: "dimas", fullName: "Dimas Anggara", phone: "0812-1111-0003", email: "dimas@example.com", parentName: "Hendra Anggara", parentPhone: "0812-1111-9003", leadSource: "BANNER" as const, status: "STUDENT" as const, joinedAt: addWeeks(-9) },
{ key: "elsa", fullName: "Elsa Maharani", phone: "0812-1111-0004", email: "elsa@example.com", parentName: "Rina Maharani", parentPhone: "0812-1111-9004", leadSource: "SOCIAL_MEDIA" as const, status: "CALON_STUDENT" as const, joinedAt: null },
{ key: "fajar", fullName: "Fajar Nugroho", phone: "0812-1111-0005", email: "fajar@example.com", parentName: "Agus Nugroho", parentPhone: "0812-1111-9005", leadSource: "WHATSAPP" as const, status: "STUDENT" as const, joinedAt: addWeeks(-8) },
{ key: "gita", fullName: "Gita Permata", phone: "0812-1111-0006", email: "gita@example.com", parentName: "Dwi Permata", parentPhone: "0812-1111-9006", leadSource: "REFERRAL" as const, status: "EX_STUDENT" as const, joinedAt: addWeeks(-40) },
{ key: "hadi", fullName: "Hadi Susanto", phone: "0812-1111-0007", email: "hadi@example.com", parentName: "Tono Susanto", parentPhone: "0812-1111-9007", leadSource: "SOCIAL_MEDIA" as const, status: "STUDENT" as const, joinedAt: addWeeks(-6) },
{ key: "intan", fullName: "Intan Puspita", phone: "0812-1111-0008", email: "intan@example.com", parentName: "Maya Puspita", parentPhone: "0812-1111-9008", leadSource: "BANNER" as const, status: "CALON_STUDENT" as const, joinedAt: null },
{ key: "joko", fullName: "Joko Wijaya", phone: "0812-1111-0009", email: "joko@example.com", parentName: "Slamet Wijaya", parentPhone: "0812-1111-9009", leadSource: "WHATSAPP" as const, status: "STUDENT" as const, joinedAt: addWeeks(-4) },
{ key: "kirana", fullName: "Kirana Dewi", phone: "0812-1111-0010", email: "kirana@example.com", parentName: "Ratna Dewi", parentPhone: "0812-1111-9010", leadSource: "REFERRAL" as const, status: "EX_STUDENT" as const, joinedAt: addWeeks(-36) },
];
const student = {} as Record<string, Awaited<ReturnType<typeof prisma.student.create>>>;
for (const s of studentsSeed) {
student[s.key] = await prisma.student.create({
data: {
fullName: s.fullName, phone: s.phone, email: s.email,
photoUrl: avatar(s.key), parentName: s.parentName, parentPhone: s.parentPhone,
leadSource: s.leadSource, status: s.status, joinedAt: s.joinedAt,
birthDate: new Date("2012-05-10T00:00:00.000Z"),
gender: "MALE", address: "Jl. Merdeka No. 10, Bandung",
},
});
}
// ─────────────────────────────────────────────────────────────
// 4. ENROLLMENT ( siswa ↔ kelas )
// ─────────────────────────────────────────────────────────────
const enrollments = [
{ key: "andi", code: "KOD-PYT-01", status: "ACTIVE" as const },
{ key: "dimas", code: "KOD-SCR-01", status: "ACTIVE" as const },
{ key: "fajar", code: "KOD-WEB-01", status: "ACTIVE" as const },
{ key: "gita", code: "KOD-JS-01", status: "COMPLETED" as const },
{ key: "hadi", code: "KOD-ARD-01", status: "ACTIVE" as const },
{ key: "joko", code: "KOD-PYT-01", status: "ACTIVE" as const },
{ key: "kirana", code: "KOD-SCR-01", status: "COMPLETED" as const },
];
for (const e of enrollments) {
await prisma.enrollment.create({
data: { studentId: student[e.key].id, classId: klass[e.code].id, status: e.status, joinedAt: student[e.key].joinedAt ?? BASE, leftAt: e.status === "COMPLETED" ? addWeeks(-2) : null },
});
}
// ─────────────────────────────────────────────────────────────
// 5. SESSION (pertemuan per kelas) — dasar prorata
// ─────────────────────────────────────────────────────────────
const session: Record<string, { id: string; meetingNumber: number }[]> = {};
for (const c of Object.values(klass)) {
session[c.code] = [];
for (let i = 1; i <= c.totalMeetings; i++) {
const s = await prisma.session.create({
data: { classId: c.id, meetingNumber: i, date: addWeeks(-10 + i - 1), topic: `Pertemuan ${i}: ${c.name}`, teacherId: c.teacherId },
});
session[c.code].push({ id: s.id, meetingNumber: i });
}
}
// ─────────────────────────────────────────────────────────────
// 6. KEHADIRAN (PRESENT/LATE/EXCUSED/SICK/ABSENT)
// Terpakai = jumlah PRESENT + LATE → dasar hitung sisa kuota
// ─────────────────────────────────────────────────────────────
const plan: Record<string, { code: string; statuses: AttendanceStatus[] }> = {
andi: { code: "KOD-PYT-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] }, // 6 terpakai
dimas: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "ABSENT", "PRESENT"] }, // 3 hadir, 1 alpa
fajar: { code: "KOD-WEB-01", statuses: ["PRESENT", "LATE", "PRESENT", "PRESENT", "EXCUSED", "PRESENT"] }, // 5 hadir
gita: { code: "KOD-JS-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
hadi: { code: "KOD-ARD-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
joko: { code: "KOD-PYT-01", statuses: ["PRESENT", "SICK", "PRESENT", "PRESENT", "PRESENT"] },
kirana: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] },
};
let attendanceCount = 0;
for (const [key, p] of Object.entries(plan)) {
const sessions = session[p.code];
for (let i = 0; i < p.statuses.length; i++) {
await prisma.attendance.create({
data: { sessionId: sessions[i].id, studentId: student[key].id, status: p.statuses[i], recordedById: klass[p.code].teacherId, note: p.statuses[i] === "SICK" ? "Demam" : undefined },
});
attendanceCount++;
}
}
// ─────────────────────────────────────────────────────────────
// 7. PEMBAYARAN PRORATA
// amount = meetingsPaid × pricePerMeeting
// Contoh: Andi 10 × 200.000 = 2.000.000
// Hadir 6 → terpakai 1.200.000 → sisa 4 pertemuan = 800.000
// ─────────────────────────────────────────────────────────────
const paymentsSeed = [
{ key: "andi", code: "KOD-PYT-01", meetingsPaid: 10, method: "TRANSFER" as const, status: "PAID" as const, paidAt: addWeeks(-10) },
{ key: "dimas", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH" as const, status: "PAID" as const, paidAt: addWeeks(-9) },
{ key: "fajar", code: "KOD-WEB-01", meetingsPaid: 6, method: "QRIS" as const, status: "PAID" as const, paidAt: addWeeks(-8), note: "Bayar 6 pertemuan dulu" },
{ key: "gita", code: "KOD-JS-01", meetingsPaid: 10, method: "TRANSFER" as const, status: "PAID" as const, paidAt: addWeeks(-40) },
{ key: "hadi", code: "KOD-ARD-01", meetingsPaid: 8, method: "TRANSFER" as const, status: "PAID" as const, paidAt: addWeeks(-6) },
{ key: "joko", code: "KOD-PYT-01", meetingsPaid: 5, method: "QRIS" as const, status: "PARTIAL" as const, paidAt: addWeeks(-4), note: "Cicilan 1 dari 2" },
{ key: "kirana", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH" as const, status: "PAID" as const, paidAt: addWeeks(-36) },
];
let paySeq = 1;
for (const p of paymentsSeed) {
const c = klass[p.code];
const ppm = c.pricePerMeeting ?? Math.round(c.packagePrice / c.totalMeetings);
await prisma.payment.create({
data: {
invoiceNumber: `INV-2026-${String(paySeq++).padStart(4, "0")}`,
studentId: student[p.key].id, classId: c.id,
meetingsPaid: p.meetingsPaid, pricePerMeeting: ppm, amount: p.meetingsPaid * ppm,
method: p.method, status: p.status as PaymentStatus, paidAt: p.paidAt, note: p.note, createdById: sari.id,
},
});
}
// ─────────────────────────────────────────────────────────────
// 8. JURNAL AKTIVITAS + FOTO
// ─────────────────────────────────────────────────────────────
const journalsSeed = [
{ code: "KOD-SCR-01", title: "Membuat Game Ular dengan Scratch", description: "Anak-anak belajar event, loop, dan variabel skor sambil membuat game ular versi mereka sendiri.", tags: ["game", "scratch", "logika"], date: addWeeks(-2), students: ["dimas", "kirana"], photos: ["ular-1", "ular-2", "ular-3"] },
{ code: "KOD-PYT-01", title: "Dasar Variabel & Tipe Data Python", description: "Sesi seru bereksperimen dengan string, integer, dan input dari pengguna.", tags: ["python", "variable", "basic"], date: addWeeks(-3), students: ["andi", "joko"], photos: ["python-1", "python-2"] },
{ code: "KOD-WEB-01", title: "Landing Page Pertama", description: "Setiap siswa mempublikasikan halaman profil pribadinya menggunakan HTML & CSS.", tags: ["html", "css", "project"], date: addWeeks(-1), students: ["fajar"], photos: ["web-1", "web-2", "web-3", "web-4"] },
{ code: "KOD-ARD-01", title: "Sensor Cahaya dengan Arduino", description: "Merakit lampu otomatis yang menyala saat ruangan gelap.", tags: ["arduino", "sensor", "robotik"], date: addDays(-5), students: ["hadi"], photos: ["arduino-1", "arduino-2"] },
];
for (const j of journalsSeed) {
const c = klass[j.code];
await prisma.journal.create({
data: {
classId: c.id, sessionId: session[j.code][0].id, title: j.title, description: j.description,
activityDate: j.date, tags: j.tags, authorId: c.teacherId!,
photos: { create: j.photos.map((s, i) => ({ url: photo(s), caption: `Dokumentasi ${i + 1}`, sortOrder: i })) },
students: { create: j.students.map((k) => ({ studentId: student[k].id })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 9. RAPORT
// ─────────────────────────────────────────────────────────────
const reportsSeed = [
{ key: "andi", code: "KOD-PYT-01", period: "2026-Term1", scores: [["Logika", 88], ["Computational Thinking", 85], ["Problem Solving", 82], ["Kreativitas", 90], ["Kolaborasi", 86]], notes: "Andi sangat aktif bertanya dan cepat memahami konsep loop." },
{ key: "dimas", code: "KOD-SCR-01", period: "2026-Term1", scores: [["Logika", 80], ["Computational Thinking", 78], ["Problem Solving", 75], ["Kreativitas", 92], ["Kolaborasi", 88]], notes: "Kreativitas Dimas luar biasa, perlu latihan ketelitian." },
{ key: "fajar", code: "KOD-WEB-01", period: "2026-Term1", scores: [["Logika", 84], ["Computational Thinking", 82], ["Problem Solving", 86], ["Kreativitas", 85], ["Kolaborasi", 80]], notes: "Progres pesat di CSS layout." },
] as const;
for (const r of reportsSeed) {
const avg = Math.round(r.scores.reduce((a, [, v]) => a + v, 0) / r.scores.length);
const grade = avg >= 90 ? "A" : avg >= 80 ? "B" : avg >= 70 ? "C" : "D";
await prisma.reportCard.create({
data: {
studentId: student[r.key].id, classId: klass[r.code].id, period: r.period,
teacherId: klass[r.code].teacherId!, finalScore: avg, grade, teacherNotes: r.notes,
attendanceSummary: "Hadir 6 dari 6 pertemuan", publishedAt: addDays(-3),
scores: { create: r.scores.map(([aspect, score]) => ({ aspect, score })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 10. PLACEMENT TEST (hasil tes penempatan → tampil di detail siswa)
// ─────────────────────────────────────────────────────────────
const placementsSeed = [
{ key: "elsa", level: "INTERMEDIATE" as PlacementLevel, rec: "KOD-JS-01", scores: [["Logika", 82], ["Dasar Coding", 76], ["Problem Solving", 80], ["Kreativitas", 74]] },
{ key: "intan", level: "BEGINNER" as PlacementLevel, rec: "KOD-SCR-01", scores: [["Logika", 62], ["Dasar Coding", 48], ["Problem Solving", 55], ["Kreativitas", 70]] },
{ key: "citra", level: "BEGINNER" as PlacementLevel, rec: "KOD-PYT-01", scores: [["Logika", 50], ["Dasar Coding", 40], ["Problem Solving", 45], ["Kreativitas", 60]] },
{ key: "andi", level: "INTERMEDIATE" as PlacementLevel, rec: "KOD-JS-01", scores: [["Logika", 80], ["Dasar Coding", 78], ["Problem Solving", 72], ["Kreativitas", 68]] },
];
for (const p of placementsSeed) {
const total = Math.round(p.scores.reduce((a, [, v]) => a + v, 0) / p.scores.length);
await prisma.placementTest.create({
data: {
studentId: student[p.key].id, testDate: addDays(-12), levelResult: p.level,
recommendedClassId: klass[p.rec].id, totalScore: total, examinerId: rina.id,
notes: `Direkomendasikan masuk kelas ${klass[p.rec].name}.`,
scores: { create: p.scores.map(([category, score]) => ({ category, score })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 11. FOLLOW UP (berapa yang harus di-follow up)
// due = nextFollowUpAt <= hari ini && status ∉ (ENROLLED, LOST)
// ─────────────────────────────────────────────────────────────
const followUpsSeed = [
{ key: "citra", status: "CONTACTED" as const, picId: sari.id, next: addDays(-1), last: addDays(-3), channel: "WHATSAPP" as const, activities: [["wa", "Kirim brosur Python Dasar, ortu minta dihubungi lagi."]] },
{ key: "elsa", status: "NEW" as const, picId: budi.id, next: addDays(0), last: null, channel: "SOCIAL_MEDIA" as const, activities: [["wa", "Lead dari Instagram, tanya jadwal kelas."]] },
{ key: "intan", status: "TRIAL" as const, picId: rina.id, next: addDays(2), last: addDays(-1), channel: "BANNER" as const, activities: [["call", "Sudah trial Scratch, tinggal konfirmasi pembayaran."]] },
];
for (const f of followUpsSeed) {
await prisma.followUp.create({
data: {
studentId: student[f.key].id, status: f.status, picId: f.picId,
nextFollowUpAt: f.next, lastContactedAt: f.last, channel: f.channel,
activities: { create: f.activities.map(([type, note]) => ({ type, note, authorId: f.picId })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 12. SETTINGS
// ─────────────────────────────────────────────────────────────
await prisma.setting.createMany({
data: [
{ key: "academy", value: { name: "Kodeva Academy", address: "Jl. Coding Raya No. 1, Bandung", phone: "0821-1000-2000", email: "halo@kodeva.id" } },
{ key: "leadSources", value: [
{ value: "WHATSAPP", label: "WhatsApp", color: "#22C55E", active: true },
{ value: "REFERRAL", label: "Referral", color: "#7C5CFC", active: true },
{ value: "BANNER", label: "Banner", color: "#FFB020", active: true },
{ value: "SOCIAL_MEDIA", label: "Social Media", color: "#2AA9FF", active: true },
] },
{ key: "currency", value: { code: "IDR", locale: "id-ID", timezone: "Asia/Jakarta" } },
],
});
// ─────────────────────────────────────────────────────────────
// Ringkasan (untuk verifikasi dashboard)
// ─────────────────────────────────────────────────────────────
const [activeStudents, exStudents, followUpDue] = await Promise.all([
prisma.student.count({ where: { status: "STUDENT" } }),
prisma.student.count({ where: { status: "EX_STUDENT" } }),
prisma.followUp.count({ where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: addDays(1) } } }),
]);
console.log("✅ Seed selesai.");
console.log(` 👥 Siswa aktif : ${activeStudents}`);
console.log(` 🎓 Ex student : ${exStudents}`);
console.log(` 🔔 Perlu follow up: ${followUpDue}`);
console.log(` 📚 Kelas : ${Object.keys(klass).length}`);
console.log(` 🗓️ Kehadiran : ${attendanceCount} record`);
console.log("\n Login: admin@kodeva.id / password123");
}
main()
.catch((e) => { console.error("❌ Seed gagal:", e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+279
View File
@@ -0,0 +1,279 @@
/**
* seed.ts — Contoh data Kodeva (Sistem Manajemen Kursus Coding)
* Jalankan: pnpm db:seed
*
* Login contoh:
* admin@kodeva.id / password123 (ADMIN)
* budi@kodeva.id / password123 (TEACHER)
* rina@kodeva.id / password123 (TEACHER)
*/
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const BASE = new Date("2026-09-18T00:00:00.000Z");
const addDays = (n: number, from: Date = BASE) => new Date(from.getTime() + n * 86_400_000);
const addWeeks = (n: number, from: Date = BASE) => addDays(n * 7, from);
const PASS = bcrypt.hashSync("password123", 10);
const avatar = (seed: string) => `https://i.pravatar.cc/240?u=${encodeURIComponent(seed)}`;
const photo = (seed: string) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/900/600`;
type Att = "PRESENT" | "LATE" | "EXCUSED" | "SICK" | "ABSENT";
type Level = "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
async function main() {
console.log("🌱 Seed Kodeva dimulai...");
await prisma.followUpActivity.deleteMany();
await prisma.followUp.deleteMany();
await prisma.placementScore.deleteMany();
await prisma.placementTest.deleteMany();
await prisma.reportScore.deleteMany();
await prisma.reportCard.deleteMany();
await prisma.journalStudent.deleteMany();
await prisma.journalPhoto.deleteMany();
await prisma.journal.deleteMany();
await prisma.payment.deleteMany();
await prisma.attendance.deleteMany();
await prisma.session.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.student.deleteMany();
await prisma.class.deleteMany();
await prisma.user.deleteMany();
await prisma.setting.deleteMany();
// 1. USERS
const sari = await prisma.user.create({
data: { name: "Sari Wulandari", email: "admin@kodeva.id", passwordHash: PASS, role: "ADMIN", phone: "0811-2000-100", avatarUrl: avatar("sari") },
});
const budi = await prisma.user.create({
data: { name: "Budi Santoso", email: "budi@kodeva.id", passwordHash: PASS, role: "TEACHER", phone: "0812-3000-200", avatarUrl: avatar("budi") },
});
const rina = await prisma.user.create({
data: { name: "Rina Kartika", email: "rina@kodeva.id", passwordHash: PASS, role: "TEACHER", phone: "0813-4000-300", avatarUrl: avatar("rina") },
});
const ahmad = await prisma.user.create({
data: { name: "Ahmad Fauzi", email: "ahmad@kodeva.id", passwordHash: PASS, role: "TEACHER", phone: "0814-5000-400", avatarUrl: avatar("ahmad") },
});
// 2. KELAS
const classesData = [
{ code: "KOD-SCR-01", name: "Scratch Junior", level: "BEGINNER" as Level, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_200_000, pricePerMeeting: 150_000, capacity: 10, schedule: "Senin & Rabu 16:00-17:30", color: "#2AA9FF", description: "Belajar logika pemrograman lewat game visual Scratch." },
{ code: "KOD-PYT-01", name: "Python Dasar", level: "BEGINNER" as Level, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_000_000, pricePerMeeting: 200_000, capacity: 12, schedule: "Selasa & Kamis 17:00-18:30", color: "#7C5CFC", description: "Fondasi Python: variabel, logika, loop, dan fungsi." },
{ code: "KOD-WEB-01", name: "Web Dev: HTML & CSS", level: "BEGINNER" as Level, teacherId: ahmad.id, totalMeetings: 12, packagePrice: 2_400_000, pricePerMeeting: 200_000, capacity: 14, schedule: "Sabtu 09:00-12:00", color: "#12B886", description: "Membangun website pertamamu dari nol." },
{ code: "KOD-JS-01", name: "JavaScript Intermediate", level: "INTERMEDIATE" as Level, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_500_000, pricePerMeeting: 250_000, capacity: 12, schedule: "Selasa & Kamis 18:45-20:15", color: "#FFB020", description: "DOM, event, async, dan mini project interaktif." },
{ code: "KOD-ARD-01", name: "Arduino & Robotics", level: "INTERMEDIATE" as Level, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_800_000, pricePerMeeting: 225_000, capacity: 8, schedule: "Jumat 15:30-17:30", color: "#F65FA7", description: "Rakit dan program robot pertamamu dengan Arduino." },
];
const klass: Record<string, { id: string; totalMeetings: number; pricePerMeeting: number | null; packagePrice: number; teacherId: string | null; name: string }> = {};
for (const c of classesData) {
const created = await prisma.class.create({ data: { ...c, startDate: addWeeks(-10), status: "ACTIVE" } });
klass[c.code] = created;
}
// 3. SISWA
const studentsSeed = [
{ key: "andi", fullName: "Andi Pratama", phone: "0812-1111-0001", email: "andi@example.com", parentName: "Budi Pratama", parentPhone: "0812-1111-9001", leadSource: "REFERRAL", status: "STUDENT", joinedAt: addWeeks(-10) },
{ key: "citra", fullName: "Citra Lestari", phone: "0812-1111-0002", email: "citra@example.com", parentName: "Sari Lestari", parentPhone: "0812-1111-9002", leadSource: "WHATSAPP", status: "CALON_STUDENT", joinedAt: null as Date | null },
{ key: "dimas", fullName: "Dimas Anggara", phone: "0812-1111-0003", email: "dimas@example.com", parentName: "Hendra Anggara", parentPhone: "0812-1111-9003", leadSource: "BANNER", status: "STUDENT", joinedAt: addWeeks(-9) },
{ key: "elsa", fullName: "Elsa Maharani", phone: "0812-1111-0004", email: "elsa@example.com", parentName: "Rina Maharani", parentPhone: "0812-1111-9004", leadSource: "SOCIAL_MEDIA", status: "CALON_STUDENT", joinedAt: null as Date | null },
{ key: "fajar", fullName: "Fajar Nugroho", phone: "0812-1111-0005", email: "fajar@example.com", parentName: "Agus Nugroho", parentPhone: "0812-1111-9005", leadSource: "WHATSAPP", status: "STUDENT", joinedAt: addWeeks(-8) },
{ key: "gita", fullName: "Gita Permata", phone: "0812-1111-0006", email: "gita@example.com", parentName: "Dwi Permata", parentPhone: "0812-1111-9006", leadSource: "REFERRAL", status: "EX_STUDENT", joinedAt: addWeeks(-40) },
{ key: "hadi", fullName: "Hadi Susanto", phone: "0812-1111-0007", email: "hadi@example.com", parentName: "Tono Susanto", parentPhone: "0812-1111-9007", leadSource: "SOCIAL_MEDIA", status: "STUDENT", joinedAt: addWeeks(-6) },
{ key: "intan", fullName: "Intan Puspita", phone: "0812-1111-0008", email: "intan@example.com", parentName: "Maya Puspita", parentPhone: "0812-1111-9008", leadSource: "BANNER", status: "CALON_STUDENT", joinedAt: null as Date | null },
{ key: "joko", fullName: "Joko Wijaya", phone: "0812-1111-0009", email: "joko@example.com", parentName: "Slamet Wijaya", parentPhone: "0812-1111-9009", leadSource: "WHATSAPP", status: "STUDENT", joinedAt: addWeeks(-4) },
{ key: "kirana", fullName: "Kirana Dewi", phone: "0812-1111-0010", email: "kirana@example.com", parentName: "Ratna Dewi", parentPhone: "0812-1111-9010", leadSource: "REFERRAL", status: "EX_STUDENT", joinedAt: addWeeks(-36) },
];
const student: Record<string, { id: string; joinedAt: Date | null }> = {};
for (const s of studentsSeed) {
const created = await prisma.student.create({
data: {
fullName: s.fullName, phone: s.phone, email: s.email, photoUrl: avatar(s.key),
parentName: s.parentName, parentPhone: s.parentPhone, leadSource: s.leadSource,
status: s.status, joinedAt: s.joinedAt,
birthDate: new Date("2012-05-10T00:00:00.000Z"), gender: "MALE", address: "Jl. Merdeka No. 10, Bandung",
},
});
student[s.key] = created;
}
// 4. ENROLLMENT
const enrollments = [
{ key: "andi", code: "KOD-PYT-01", status: "ACTIVE" },
{ key: "dimas", code: "KOD-SCR-01", status: "ACTIVE" },
{ key: "fajar", code: "KOD-WEB-01", status: "ACTIVE" },
{ key: "gita", code: "KOD-JS-01", status: "COMPLETED" },
{ key: "hadi", code: "KOD-ARD-01", status: "ACTIVE" },
{ key: "joko", code: "KOD-PYT-01", status: "ACTIVE" },
{ key: "kirana", code: "KOD-SCR-01", status: "COMPLETED" },
];
for (const e of enrollments) {
await prisma.enrollment.create({
data: { studentId: student[e.key].id, classId: klass[e.code].id, status: e.status, joinedAt: student[e.key].joinedAt ?? BASE, leftAt: e.status === "COMPLETED" ? addWeeks(-2) : null },
});
}
// 5. SESSION
const session: Record<string, { id: string }[]> = {};
for (const code of Object.keys(klass)) {
const c = klass[code];
session[code] = [];
for (let i = 1; i <= c.totalMeetings; i++) {
const s = await prisma.session.create({
data: { classId: c.id, meetingNumber: i, date: addWeeks(-10 + i - 1), topic: `Pertemuan ${i}: ${c.name}`, teacherId: c.teacherId },
});
session[code].push({ id: s.id });
}
}
// 6. KEHADIRAN
const plan: Record<string, { code: string; statuses: Att[] }> = {
andi: { code: "KOD-PYT-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] },
dimas: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "ABSENT", "PRESENT"] },
fajar: { code: "KOD-WEB-01", statuses: ["PRESENT", "LATE", "PRESENT", "PRESENT", "EXCUSED", "PRESENT"] },
gita: { code: "KOD-JS-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
hadi: { code: "KOD-ARD-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
joko: { code: "KOD-PYT-01", statuses: ["PRESENT", "SICK", "PRESENT", "PRESENT", "PRESENT"] },
kirana: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] },
};
let attendanceCount = 0;
for (const [key, p] of Object.entries(plan)) {
for (let i = 0; i < p.statuses.length; i++) {
await prisma.attendance.create({
data: { sessionId: session[p.code][i].id, studentId: student[key].id, status: p.statuses[i], recordedById: klass[p.code].teacherId, note: p.statuses[i] === "SICK" ? "Demam" : null },
});
attendanceCount++;
}
}
// 7. PEMBAYARAN PRORATA
const paymentsSeed = [
{ key: "andi", code: "KOD-PYT-01", meetingsPaid: 10, method: "TRANSFER", status: "PAID", paidAt: addWeeks(-10) },
{ key: "dimas", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH", status: "PAID", paidAt: addWeeks(-9) },
{ key: "fajar", code: "KOD-WEB-01", meetingsPaid: 6, method: "QRIS", status: "PAID", paidAt: addWeeks(-8), note: "Bayar 6 pertemuan dulu" },
{ key: "gita", code: "KOD-JS-01", meetingsPaid: 10, method: "TRANSFER", status: "PAID", paidAt: addWeeks(-40) },
{ key: "hadi", code: "KOD-ARD-01", meetingsPaid: 8, method: "TRANSFER", status: "PAID", paidAt: addWeeks(-6) },
{ key: "joko", code: "KOD-PYT-01", meetingsPaid: 5, method: "QRIS", status: "PARTIAL", paidAt: addWeeks(-4), note: "Cicilan 1 dari 2" },
{ key: "kirana", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH", status: "PAID", paidAt: addWeeks(-36) },
];
let paySeq = 1;
const createdPayments: { key: string; code: string; meetingsPaid: number; ppm: number }[] = [];
for (const p of paymentsSeed) {
const c = klass[p.code];
const ppm = c.pricePerMeeting ?? Math.round(c.packagePrice / c.totalMeetings);
await prisma.payment.create({
data: {
invoiceNumber: `INV-2026-${String(paySeq++).padStart(4, "0")}`,
studentId: student[p.key].id, classId: c.id,
meetingsPaid: p.meetingsPaid, pricePerMeeting: ppm, amount: p.meetingsPaid * ppm,
method: p.method, status: p.status, paidAt: p.paidAt, note: p.note ?? null, createdById: sari.id,
},
});
createdPayments.push({ key: p.key, code: p.code, meetingsPaid: p.meetingsPaid, ppm });
}
// 8. JURNAL
const journalsSeed = [
{ code: "KOD-SCR-01", title: "Membuat Game Ular dengan Scratch", description: "Anak-anak belajar event, loop, dan variabel skor sambil membuat game ular versi mereka sendiri.", tags: ["game", "scratch", "logika"], date: addWeeks(-2), students: ["dimas", "kirana"], photos: ["ular-1", "ular-2", "ular-3"] },
{ code: "KOD-PYT-01", title: "Dasar Variabel & Tipe Data Python", description: "Sesi seru bereksperimen dengan string, integer, dan input dari pengguna.", tags: ["python", "variable", "basic"], date: addWeeks(-3), students: ["andi", "joko"], photos: ["python-1", "python-2"] },
{ code: "KOD-WEB-01", title: "Landing Page Pertama", description: "Setiap siswa mempublikasikan halaman profil pribadinya menggunakan HTML & CSS.", tags: ["html", "css", "project"], date: addWeeks(-1), students: ["fajar"], photos: ["web-1", "web-2", "web-3", "web-4"] },
{ code: "KOD-ARD-01", title: "Sensor Cahaya dengan Arduino", description: "Merakit lampu otomatis yang menyala saat ruangan gelap.", tags: ["arduino", "sensor", "robotik"], date: addDays(-5), students: ["hadi"], photos: ["arduino-1", "arduino-2"] },
];
for (const j of journalsSeed) {
const c = klass[j.code];
await prisma.journal.create({
data: {
classId: c.id, sessionId: session[j.code][0].id, title: j.title, description: j.description,
activityDate: j.date, tags: j.tags.join(","), authorId: c.teacherId!,
photos: { create: j.photos.map((s, i) => ({ url: photo(s), caption: `Dokumentasi ${i + 1}`, sortOrder: i })) },
students: { create: j.students.map((k) => ({ studentId: student[k].id })) },
},
});
}
// 9. RAPORT
const reportsSeed = [
{ key: "andi", code: "KOD-PYT-01", period: "2026-Term1", scores: [["Logika", 88], ["Computational Thinking", 85], ["Problem Solving", 82], ["Kreativitas", 90], ["Kolaborasi", 86]], notes: "Andi sangat aktif bertanya dan cepat memahami konsep loop." },
{ key: "dimas", code: "KOD-SCR-01", period: "2026-Term1", scores: [["Logika", 80], ["Computational Thinking", 78], ["Problem Solving", 75], ["Kreativitas", 92], ["Kolaborasi", 88]], notes: "Kreativitas Dimas luar biasa, perlu latihan ketelitian." },
{ key: "fajar", code: "KOD-WEB-01", period: "2026-Term1", scores: [["Logika", 84], ["Computational Thinking", 82], ["Problem Solving", 86], ["Kreativitas", 85], ["Kolaborasi", 80]], notes: "Progres pesat di CSS layout." },
] as { key: string; code: string; period: string; scores: [string, number][]; notes: string }[];
for (const r of reportsSeed) {
const avg = Math.round(r.scores.reduce((a, [, v]) => a + v, 0) / r.scores.length);
const grade = avg >= 90 ? "A" : avg >= 80 ? "B" : avg >= 70 ? "C" : "D";
await prisma.reportCard.create({
data: {
studentId: student[r.key].id, classId: klass[r.code].id, period: r.period,
teacherId: klass[r.code].teacherId!, finalScore: avg, grade, teacherNotes: r.notes,
attendanceSummary: "Hadir 6 dari 6 pertemuan", publishedAt: addDays(-3),
scores: { create: r.scores.map(([aspect, score]) => ({ aspect, score })) },
},
});
}
// 10. PLACEMENT TEST
const placementsSeed = [
{ key: "elsa", level: "INTERMEDIATE" as Level, rec: "KOD-JS-01", scores: [["Logika", 82], ["Dasar Coding", 76], ["Problem Solving", 80], ["Kreativitas", 74]] },
{ key: "intan", level: "BEGINNER" as Level, rec: "KOD-SCR-01", scores: [["Logika", 62], ["Dasar Coding", 48], ["Problem Solving", 55], ["Kreativitas", 70]] },
{ key: "citra", level: "BEGINNER" as Level, rec: "KOD-PYT-01", scores: [["Logika", 50], ["Dasar Coding", 40], ["Problem Solving", 45], ["Kreativitas", 60]] },
{ key: "andi", level: "INTERMEDIATE" as Level, rec: "KOD-JS-01", scores: [["Logika", 80], ["Dasar Coding", 78], ["Problem Solving", 72], ["Kreativitas", 68]] },
] as { key: string; level: Level; rec: string; scores: [string, number][] }[];
for (const p of placementsSeed) {
const total = Math.round(p.scores.reduce((a, [, v]) => a + v, 0) / p.scores.length);
await prisma.placementTest.create({
data: {
studentId: student[p.key].id, testDate: addDays(-12), levelResult: p.level,
recommendedClassId: klass[p.rec].id, totalScore: total, examinerId: rina.id,
notes: `Direkomendasikan masuk kelas ${klass[p.rec].name}.`,
scores: { create: p.scores.map(([category, score]) => ({ category, score })) },
},
});
}
// 11. FOLLOW UP
const followUpsSeed = [
{ key: "citra", status: "CONTACTED", picId: sari.id, next: addDays(-1), last: addDays(-3), channel: "WHATSAPP", activities: [["wa", "Kirim brosur Python Dasar, ortu minta dihubungi lagi."]] },
{ key: "elsa", status: "NEW", picId: budi.id, next: addDays(0), last: null as Date | null, channel: "SOCIAL_MEDIA", activities: [["wa", "Lead dari Instagram, tanya jadwal kelas."]] },
{ key: "intan", status: "TRIAL", picId: rina.id, next: addDays(2), last: addDays(-1), channel: "BANNER", activities: [["call", "Sudah trial Scratch, tinggal konfirmasi pembayaran."]] },
] as { key: string; status: string; picId: string; next: Date; last: Date | null; channel: string; activities: [string, string][] }[];
for (const f of followUpsSeed) {
await prisma.followUp.create({
data: {
studentId: student[f.key].id, status: f.status, picId: f.picId,
nextFollowUpAt: f.next, lastContactedAt: f.last, channel: f.channel,
activities: { create: f.activities.map(([type, note]) => ({ type, note, authorId: f.picId })) },
},
});
}
// 12. SETTINGS
await prisma.setting.createMany({
data: [
{ key: "academy", value: JSON.stringify({ name: "Kodeva Academy", address: "Jl. Coding Raya No. 1, Bandung", phone: "0821-1000-2000", email: "halo@kodeva.id" }) },
{ key: "leadSources", value: JSON.stringify([
{ value: "WHATSAPP", label: "WhatsApp", color: "#22C55E", active: true },
{ value: "REFERRAL", label: "Referral", color: "#7C5CFC", active: true },
{ value: "BANNER", label: "Banner", color: "#FFB020", active: true },
{ value: "SOCIAL_MEDIA", label: "Social Media", color: "#2AA9FF", active: true },
]) },
{ key: "currency", value: JSON.stringify({ code: "IDR", locale: "id-ID", timezone: "Asia/Jakarta" }) },
],
});
const [activeStudents, exStudents, followUpDue] = await Promise.all([
prisma.student.count({ where: { status: "STUDENT" } }),
prisma.student.count({ where: { status: "EX_STUDENT" } }),
prisma.followUp.count({ where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: addDays(1) } } }),
]);
console.log("✅ Seed selesai.");
console.log(` 👥 Siswa aktif : ${activeStudents}`);
console.log(` 🎓 Ex student : ${exStudents}`);
console.log(` 🔔 Perlu follow up : ${followUpDue}`);
console.log(` 📚 Kelas : ${Object.keys(klass).length}`);
console.log(` 🗓️ Kehadiran : ${attendanceCount} record`);
console.log("\n Login: admin@kodeva.id / password123");
}
main()
.catch((e) => { console.error("❌ Seed gagal:", e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+37
View File
@@ -0,0 +1,37 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { attendanceBulkSchema } from "@/lib/validators";
export type AttendanceState = { error?: string; ok?: boolean } | undefined;
export async function saveAttendanceAction(input: {
sessionId: string;
entries: { studentId: string; status: string; note?: string }[];
}): Promise<AttendanceState> {
const user = await requireUser();
const parsed = attendanceBulkSchema.safeParse(input);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
try {
for (const e of parsed.data.entries) {
await prisma.attendance.upsert({
where: { sessionId_studentId: { sessionId: parsed.data.sessionId, studentId: e.studentId } },
update: { status: e.status, note: e.note?.trim() || null, recordedById: user.userId },
create: {
sessionId: parsed.data.sessionId,
studentId: e.studentId,
status: e.status,
note: e.note?.trim() || null,
recordedById: user.userId,
},
});
}
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal menyimpan kehadiran." };
}
revalidatePath("/attendance");
return { ok: true };
}
+133
View File
@@ -0,0 +1,133 @@
import { CalendarCheck } from "lucide-react";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { AttendanceSheet } from "@/components/attendance/attendance-sheet";
import { ATTENDANCE_META } from "@/lib/constants";
import { Badge } from "@/components/ui/badge";
export const metadata = { title: "Kehadiran — Kodeva" };
type SP = Promise<{ classId?: string; sessionId?: string }>;
export default async function AttendancePage({ searchParams }: { searchParams: SP }) {
await requireUser();
const sp = await searchParams;
const classes = await prisma.class.findMany({
where: { status: "ACTIVE" },
include: { sessions: { orderBy: { meetingNumber: "asc" } } },
orderBy: { name: "asc" },
});
const cls = sp.classId ? classes.find((c) => c.id === sp.classId) : undefined;
const session = sp.sessionId ? cls?.sessions.find((s) => s.id === sp.sessionId) : undefined;
let students: { id: string; fullName: string; photoUrl: string | null }[] = [];
let existing: Record<string, string> = {};
if (session) {
const [roster, atts] = await Promise.all([
prisma.enrollment.findMany({
where: { classId: session.classId, status: "ACTIVE" },
include: { student: true },
orderBy: { student: { fullName: "asc" } },
}),
prisma.attendance.findMany({ where: { sessionId: session.id } }),
]);
students = roster.map((r) => ({ id: r.student.id, fullName: r.student.fullName, photoUrl: r.student.photoUrl }));
existing = Object.fromEntries(atts.map((a) => [a.studentId, a.status]));
}
return (
<div>
<PageHeader title="Kehadiran" description="Rekap kehadiran siswa per pertemuan." />
{/* Legend */}
<div className="mb-4 flex flex-wrap gap-2">
{Object.entries(ATTENDANCE_META).map(([k, m]) => (
<Badge key={k} bg={m.bg} fg={m.text} dot={m.color}>
{m.short} = {m.label}
</Badge>
))}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[260px_1fr]">
{/* Pilih kelas & sesi */}
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Pilih Kelas</CardTitle>
</CardHeader>
<CardBody className="space-y-1.5 pt-0">
{classes.map((c) => (
<Link
key={c.id}
href={`/attendance?classId=${c.id}`}
className={`block rounded-[12px] px-3 py-2.5 text-[13.5px] font-medium transition-colors ${
sp.classId === c.id ? "grad-primary text-white" : "text-ink-700 hover:bg-brand-50"
}`}
>
<span className="mr-2 inline-block size-2 rounded-full" style={{ backgroundColor: sp.classId === c.id ? "#fff" : c.color ?? "#7C5CFC" }} />
{c.name}
</Link>
))}
</CardBody>
</Card>
{cls ? (
<Card>
<CardHeader>
<CardTitle>Pilih Pertemuan</CardTitle>
</CardHeader>
<CardBody className="max-h-[320px] space-y-1.5 overflow-y-auto pt-0">
{cls.sessions.map((s) => (
<Link
key={s.id}
href={`/attendance?classId=${cls.id}&sessionId=${s.id}`}
className={`block rounded-[12px] px-3 py-2.5 text-[13px] transition-colors ${
sp.sessionId === s.id ? "bg-brand-100 text-brand-700 font-semibold" : "text-ink-700 hover:bg-canvas"
}`}
>
<span className="font-num font-semibold">#{s.meetingNumber}</span> · {formatDateID(s.date, "d MMM")}
<span className="block text-[11.5px] text-ink-500">{s.topic}</span>
</Link>
))}
</CardBody>
</Card>
) : null}
</div>
{/* Lembar absensi */}
<div>
{!session ? (
<EmptyState
icon={<CalendarCheck className="size-6" />}
title="Pilih kelas dan pertemuan"
description="Pilih kelas di kiri, lalu pertemuan untuk mengisi kehadiran."
/>
) : (
<Card>
<CardHeader>
<CardTitle>
{cls?.name} Pertemuan {session.meetingNumber}
</CardTitle>
<span className="text-[12px] text-ink-500">{formatDateID(session.date, "EEEE, d MMM yyyy")}</span>
</CardHeader>
<CardBody className="pt-0">
{students.length === 0 ? (
<EmptyState title="Belum ada siswa terdaftar di kelas ini" />
) : (
<AttendanceSheet sessionId={session.id} students={students} existing={existing} />
)}
</CardBody>
</Card>
)}
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { requireAdmin } from "@/lib/rbac";
import { prisma } from "@/lib/prisma";
import { PageHeader } from "@/components/ui/page-header";
import { ClassForm } from "@/components/classes/class-form";
import { updateClassAction } from "../../actions";
export const metadata = { title: "Edit Kelas — Kodeva" };
export default async function EditClassPage({ params }: { params: Promise<{ id: string }> }) {
await requireAdmin();
const { id } = await params;
const [cls, teachers] = await Promise.all([
prisma.class.findUnique({ where: { id } }),
prisma.user.findMany({ where: { role: "TEACHER", isActive: true }, orderBy: { name: "asc" } }),
]);
if (!cls) notFound();
return (
<div>
<PageHeader title={`Edit ${cls.name}`} description="Perbarui konfigurasi kelas." />
<ClassForm
action={updateClassAction}
teachers={teachers}
mode="edit"
initial={{
id: cls.id,
name: cls.name,
code: cls.code,
description: cls.description,
level: cls.level,
teacherId: cls.teacherId,
totalMeetings: cls.totalMeetings,
packagePrice: cls.packagePrice,
pricePerMeeting: cls.pricePerMeeting,
capacity: cls.capacity,
schedule: cls.schedule,
color: cls.color,
status: cls.status,
startDate: cls.startDate ? new Date(cls.startDate).toISOString().slice(0, 10) : "",
}}
/>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { CalendarDays, Clock, Pencil, Plus, Trash2, Users } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { getPricePerMeeting } from "@/lib/prorata";
import { formatDateID, formatIDR } from "@/lib/utils";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { LevelBadge, StatusBadge } from "@/components/students/badges";
import { archiveClassAction, generateSessionsAction } from "../actions";
export const metadata = { title: "Detail Kelas — Kodeva" };
export default async function ClassDetailPage({ params }: { params: Promise<{ id: string }> }) {
const user = await requireUser();
const { id } = await params;
const cls = await prisma.class.findUnique({
where: { id },
include: {
teacher: true,
sessions: { include: { _count: { select: { attendances: true } } }, orderBy: { meetingNumber: "asc" } },
enrollments: { where: { status: "ACTIVE" }, include: { student: true } },
},
});
if (!cls) notFound();
const ppm = getPricePerMeeting(cls);
return (
<div className="space-y-6">
{/* Header */}
<div className="overflow-hidden rounded-[24px] border border-ink-100 bg-surface shadow-[var(--shadow-sm)]">
<div className="h-3" style={{ backgroundColor: cls.color ?? "#7C5CFC" }} />
<div className="flex flex-col gap-4 p-6 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-[24px] font-extrabold text-ink-900">{cls.name}</h1>
<LevelBadge level={cls.level} />
</div>
<p className="font-mono text-[12px] text-ink-500">{cls.code}</p>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1.5 text-[13px] text-ink-700">
<span className="flex items-center gap-1.5">
<Clock className="size-4 text-ink-500" /> {cls.schedule ?? "Jadwal belum diatur"}
</span>
<span className="flex items-center gap-1.5">
<Users className="size-4 text-ink-500" /> {cls.teacher?.name ?? "Belum ada guru"}
</span>
<span className="flex items-center gap-1.5">
<CalendarDays className="size-4 text-ink-500" /> {cls.totalMeetings} pertemuan
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={`/classes/${cls.id}/edit`}>
<Button variant="outline">
<Pencil className="size-4" /> Edit
</Button>
</Link>
<form action={generateSessionsAction}>
<input type="hidden" name="classId" value={cls.id} />
<Button variant="secondary" type="submit">
<Plus className="size-4" /> {cls.sessions.length ? "Lengkapi Sesi" : "Generate Sesi"}
</Button>
</form>
{user.role === "ADMIN" ? (
<form action={archiveClassAction}>
<input type="hidden" name="id" value={cls.id} />
<Button variant="ghost" className="text-danger-500 hover:bg-danger-100">
<Trash2 className="size-4" /> Arsipkan
</Button>
</form>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-4 border-t border-ink-100 px-6 py-4 sm:grid-cols-4">
<MiniStat label="Harga paket" value={formatIDR(cls.packagePrice)} />
<MiniStat label="Per pertemuan" value={formatIDR(ppm)} />
<MiniStat label="Kapasitas" value={`${cls.enrollments.length}/${cls.capacity}`} />
<MiniStat label="Status" value={cls.status === "ACTIVE" ? "Aktif" : cls.status} />
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Roster */}
<Card>
<CardHeader>
<CardTitle>Siswa Terdaftar</CardTitle>
<span className="text-[12px] text-ink-500">{cls.enrollments.length} siswa</span>
</CardHeader>
<CardBody className="pt-0">
{cls.enrollments.length === 0 ? (
<EmptyState icon={<Users className="size-6" />} title="Belum ada siswa" />
) : (
<div className="space-y-2">
{cls.enrollments.map((e) => (
<Link key={e.id} href={`/students/${e.student.id}`} className="flex items-center gap-3 rounded-[12px] px-2 py-2 hover:bg-canvas">
<Avatar src={e.student.photoUrl} name={e.student.fullName} size={36} />
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-semibold text-ink-900">{e.student.fullName}</p>
<p className="truncate text-[12px] text-ink-500">{e.student.phone}</p>
</div>
<StatusBadge status={e.student.status} />
</Link>
))}
</div>
)}
</CardBody>
</Card>
{/* Sessions */}
<Card>
<CardHeader>
<CardTitle>Daftar Pertemuan</CardTitle>
<span className="text-[12px] text-ink-500">{cls.sessions.length}/{cls.totalMeetings} dibuat</span>
</CardHeader>
<CardBody className="pt-0">
{cls.sessions.length === 0 ? (
<EmptyState title="Belum ada sesi" description="Klik 'Generate Sesi' untuk membuat jadwal pertemuan." />
) : (
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{cls.sessions.map((s) => (
<div key={s.id} className="flex items-center gap-3 rounded-[12px] border border-ink-100 px-3 py-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-brand-50 font-num text-[12px] font-bold text-brand-700">
{s.meetingNumber}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-ink-900">{s.topic ?? `Pertemuan ${s.meetingNumber}`}</p>
<p className="text-[12px] text-ink-500">{formatDateID(s.date, "EEE, d MMM yyyy")}</p>
</div>
<span className="text-[12px] text-ink-500">{s._count.attendances} hadir</span>
</div>
))}
</div>
)}
</CardBody>
</Card>
</div>
</div>
);
}
function MiniStat({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11.5px] text-ink-500">{label}</p>
<p className="font-num text-[16px] font-bold text-ink-900">{value}</p>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/rbac";
import { classSchema } from "@/lib/validators";
export type ClassState = { error?: string } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function createClassAction(_prev: ClassState, formData: FormData): Promise<ClassState> {
await requireAdmin();
const parsed = classSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
const exists = await prisma.class.findUnique({ where: { code: d.code } });
if (exists) return { error: `Kode kelas ${d.code} sudah dipakai.` };
try {
await prisma.class.create({
data: {
name: d.name,
code: d.code,
description: nullable(d.description),
level: d.level,
teacherId: nullable(d.teacherId),
totalMeetings: d.totalMeetings,
packagePrice: d.packagePrice,
pricePerMeeting: d.pricePerMeeting && d.pricePerMeeting > 0 ? d.pricePerMeeting : null,
capacity: d.capacity,
schedule: nullable(d.schedule),
color: nullable(d.color) ?? "#7C5CFC",
status: d.status,
startDate: parseDate(d.startDate),
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal membuat kelas." };
}
revalidatePath("/classes");
redirect("/classes");
}
export async function updateClassAction(_prev: ClassState, formData: FormData): Promise<ClassState> {
await requireAdmin();
const id = String(formData.get("id"));
const parsed = classSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
try {
await prisma.class.update({
where: { id },
data: {
name: d.name,
code: d.code,
description: nullable(d.description),
level: d.level,
teacherId: nullable(d.teacherId),
totalMeetings: d.totalMeetings,
packagePrice: d.packagePrice,
pricePerMeeting: d.pricePerMeeting && d.pricePerMeeting > 0 ? d.pricePerMeeting : null,
capacity: d.capacity,
schedule: nullable(d.schedule),
color: nullable(d.color) ?? "#7C5CFC",
status: d.status,
startDate: parseDate(d.startDate),
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal memperbarui kelas." };
}
revalidatePath("/classes");
revalidatePath(`/classes/${id}`);
redirect("/classes");
}
export async function archiveClassAction(formData: FormData) {
await requireAdmin();
const id = String(formData.get("id"));
await prisma.class.update({ where: { id }, data: { status: "ARCHIVED" } });
revalidatePath("/classes");
redirect("/classes");
}
export async function generateSessionsAction(formData: FormData) {
await requireAdmin();
const classId = String(formData.get("classId"));
const cls = await prisma.class.findUnique({ where: { id: classId }, include: { sessions: true } });
if (!cls) redirect("/classes");
const existing = new Set(cls.sessions.map((s) => s.meetingNumber));
const base = cls.startDate ?? new Date();
const created = [];
for (let i = 1; i <= cls.totalMeetings; i++) {
if (existing.has(i)) continue;
const date = new Date(base);
date.setDate(date.getDate() + (i - 1) * 7);
const s = await prisma.session.create({
data: { classId, meetingNumber: i, date, topic: `Pertemuan ${i}: ${cls.name}`, teacherId: cls.teacherId },
});
created.push(s.id);
}
revalidatePath(`/classes/${classId}`);
redirect(`/classes/${classId}`);
}
+18
View File
@@ -0,0 +1,18 @@
import { requireAdmin } from "@/lib/rbac";
import { prisma } from "@/lib/prisma";
import { PageHeader } from "@/components/ui/page-header";
import { ClassForm } from "@/components/classes/class-form";
import { createClassAction } from "../actions";
export const metadata = { title: "Tambah Kelas — Kodeva" };
export default async function NewClassPage() {
await requireAdmin();
const teachers = await prisma.user.findMany({ where: { role: "TEACHER", isActive: true }, orderBy: { name: "asc" } });
return (
<div>
<PageHeader title="Tambah Kelas" description="Konfigurasi kelas baru beserta paket dan pengajarnya." />
<ClassForm action={createClassAction} teachers={teachers} mode="create" />
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { Clock, GraduationCap, Plus, Users } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { getPricePerMeeting } from "@/lib/prorata";
import { formatIDR } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { LevelBadge } from "@/components/students/badges";
import { Card } from "@/components/ui/card";
export const metadata = { title: "Kelas — Kodeva" };
export default async function ClassesPage() {
await requireUser();
const classes = await prisma.class.findMany({
where: { status: { not: "ARCHIVED" } },
include: { teacher: true, _count: { select: { enrollments: { where: { status: "ACTIVE" } }, sessions: true } } },
orderBy: { createdAt: "desc" },
});
return (
<div>
<PageHeader
title="Kelas"
description="Konfigurasi kelas, jadwal, paket, dan pengajar."
actions={
<Link href="/classes/new">
<Button>
<Plus className="size-4" /> Tambah Kelas
</Button>
</Link>
}
/>
{classes.length === 0 ? (
<EmptyState icon={<GraduationCap className="size-6" />} title="Belum ada kelas" description="Buat kelas pertamamu untuk mulai mendaftarkan siswa." />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{classes.map((c) => {
const ppm = getPricePerMeeting(c);
const used = c._count.enrollments;
const progress = c.capacity ? Math.min(100, Math.round((used / c.capacity) * 100)) : 0;
return (
<Link key={c.id} href={`/classes/${c.id}`}>
<Card className="group h-full overflow-hidden transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-md)]">
<div className="h-2" style={{ backgroundColor: c.color ?? "#7C5CFC" }} />
<div className="p-5">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-[16px] font-semibold text-ink-900 group-hover:text-brand-600">{c.name}</h3>
<p className="font-mono text-[11px] text-ink-500">{c.code}</p>
</div>
<LevelBadge level={c.level} />
</div>
{c.description ? <p className="mt-2 line-clamp-2 text-[13px] text-ink-500">{c.description}</p> : null}
<div className="mt-3 space-y-1.5 text-[12.5px] text-ink-500">
<p className="flex items-center gap-2">
<Clock className="size-3.5" /> {c.schedule ?? "Jadwal belum diatur"}
</p>
<p className="flex items-center gap-2">
<Users className="size-3.5" /> {c.teacher?.name ?? "Belum ada guru"}
</p>
</div>
<div className="mt-4 border-t border-ink-100 pt-3">
<div className="mb-1 flex justify-between text-[12px] text-ink-500">
<span>
Kapasitas {used}/{c.capacity}
</span>
<span>
{c.totalMeetings} pertemuan · <b className="text-ink-900">{formatIDR(ppm)}</b>/sesi
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-ink-100">
<div className="h-full rounded-full" style={{ width: `${progress}%`, backgroundColor: c.color ?? "#7C5CFC" }} />
</div>
</div>
</div>
</Card>
</Link>
);
})}
</div>
)}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import Link from "next/link";
import {
ArrowRight,
BellRing,
CalendarCheck,
Clock,
GraduationCap,
MessageCircle,
TrendingUp,
Users,
} from "lucide-react";
import { requireUser } from "@/lib/rbac";
import { getDashboardSummary } from "@/lib/queries";
import { formatRelativeID } from "@/lib/utils";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { StatCard } from "@/components/ui/stat-card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { Button } from "@/components/ui/button";
import { ActiveStudentsChart } from "@/components/charts/active-students-chart";
import { AttendanceDonut } from "@/components/charts/attendance-donut";
import { LeadSourceBar } from "@/components/charts/lead-source-bar";
import { StatusBadge } from "@/components/students/badges";
export const metadata = { title: "Dashboard — Kodeva" };
function greeting() {
const h = new Date().getHours();
if (h < 11) return "Selamat pagi";
if (h < 15) return "Selamat siang";
if (h < 19) return "Selamat sore";
return "Selamat malam";
}
export default async function DashboardPage() {
const [user, s] = await Promise.all([requireUser(), getDashboardSummary()]);
const today = new Date().toLocaleDateString("id-ID", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
return (
<div className="space-y-6">
{/* Hero */}
<div className="grad-primary animate-fade-up relative overflow-hidden rounded-[24px] p-6 text-white shadow-[var(--shadow-brand)]">
<div className="pointer-events-none absolute -right-10 -top-10 size-48 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -bottom-16 right-24 size-40 rounded-full bg-white/10" />
<p className="text-[13px] text-white/80">{today}</p>
<h1 className="mt-1 text-[28px] font-extrabold leading-tight">
{greeting()}, {user.name.split(" ")[0]} 👋
</h1>
<p className="mt-1 max-w-lg text-[13.5px] text-white/85">
Ada <span className="font-bold text-white">{s.followUpDue} siswa</span> yang perlu di-follow up hari ini. Yuk
rapikan sebelum terlambat!
</p>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Siswa Aktif" value={s.counts.activeStudents} icon={<Users className="size-5" />} gradient="grad-primary" href="/students?status=STUDENT" />
<StatCard label="Perlu Follow Up" value={s.followUpDue} icon={<BellRing className="size-5" />} gradient="grad-coral" href="/follow-up" />
<StatCard label="Ex Student" value={s.counts.exStudents} icon={<GraduationCap className="size-5" />} gradient="grad-mint" href="/students?status=EX_STUDENT" />
<StatCard label="Calon Student" value={s.counts.calonStudents} icon={<TrendingUp className="size-5" />} gradient="grad-pink" href="/students?status=CALON_STUDENT" />
</div>
{/* Content + Right rail */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
{/* Main */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BellRing className="size-4 text-coral-500" /> Perlu Follow Up Hari Ini
</CardTitle>
<Link href="/follow-up" className="text-[13px] font-semibold text-brand-600 hover:underline">
Lihat semua
</Link>
</CardHeader>
<CardBody className="pt-0">
{s.followUps.length === 0 ? (
<EmptyState title="Yeay, tidak ada follow up yang telat hari ini 🎉" description="Semua calon siswa sudah ditindaklanjuti." />
) : (
<div className="divide-y divide-ink-100">
{s.followUps.map((f) => (
<div key={f.id} className="flex items-center gap-3 py-3">
<Avatar src={f.student.photoUrl} name={f.student.fullName} size={40} />
<div className="min-w-0 flex-1">
<Link href={`/students/${f.student.id}`} className="truncate text-[14px] font-semibold text-ink-900 hover:text-brand-600">
{f.student.fullName}
</Link>
<p className="truncate text-[12px] text-ink-500">
{f.student.phone} · PIC {f.pic?.name ?? "-"}
</p>
</div>
<div className="hidden text-right sm:block">
<StatusBadge status={f.student.status} />
<p className="mt-1 text-[11px] font-medium text-danger-500">
Due {formatRelativeID(f.nextFollowUpAt)}
</p>
</div>
<a
href={`https://wa.me/${f.student.phone.replace(/[^0-9]/g, "").replace(/^0/, "62")}`}
target="_blank"
rel="noreferrer"
className="flex size-9 items-center justify-center rounded-[12px] bg-mint-100 text-mint-500 hover:brightness-95"
aria-label="Hubungi WhatsApp"
>
<MessageCircle className="size-[18px]" />
</a>
</div>
))}
</div>
)}
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarCheck className="size-4 text-brand-500" /> Jadwal Kelas Aktif
</CardTitle>
<Link href="/classes" className="text-[13px] font-semibold text-brand-600 hover:underline">
Kelola kelas
</Link>
</CardHeader>
<CardBody className="grid grid-cols-1 gap-3 pt-0 sm:grid-cols-2">
{s.classes.map((c) => (
<Link
key={c.id}
href={`/classes/${c.id}`}
className="group flex items-center gap-3 rounded-[16px] border border-ink-100 p-3 transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]"
>
<span className="size-10 shrink-0 rounded-[12px]" style={{ backgroundColor: `${c.color ?? "#7C5CFC"}22` }}>
<span className="flex h-full w-full items-center justify-center text-[13px] font-bold" style={{ color: c.color ?? "#7C5CFC" }}>
{c.code.slice(-2)}
</span>
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-semibold text-ink-900">{c.name}</p>
<p className="flex items-center gap-1 truncate text-[12px] text-ink-500">
<Clock className="size-3" /> {c.schedule ?? "Jadwal belum diatur"}
</p>
</div>
<div className="text-right">
<p className="font-num text-[15px] font-bold text-ink-900">
{c._count.enrollments}
<span className="text-[11px] font-medium text-ink-500">/{c.capacity}</span>
</p>
<p className="text-[11px] text-ink-500">{c.teacher?.name.split(" ")[0] ?? "-"}</p>
</div>
</Link>
))}
</CardBody>
</Card>
</div>
{/* Right rail — dashboard grafik */}
<aside className="space-y-4 xl:sticky xl:top-20 xl:self-start">
<Card>
<CardHeader>
<CardTitle>Kehadiran Siswa</CardTitle>
</CardHeader>
<CardBody className="pt-0">
<AttendanceDonut
present={s.attendance.present}
late={s.attendance.late}
absent={s.attendance.absent}
rate={s.attendance.rate}
/>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-[12px] bg-mint-100 py-2">
<p className="font-num text-[16px] font-bold text-mint-500">{s.attendance.present}</p>
<p className="text-[11px] text-ink-500">Hadir</p>
</div>
<div className="rounded-[12px] bg-amber-100 py-2">
<p className="font-num text-[16px] font-bold text-amber-500">{s.attendance.late}</p>
<p className="text-[11px] text-ink-500">Telat</p>
</div>
<div className="rounded-[12px] bg-danger-100 py-2">
<p className="font-num text-[16px] font-bold text-danger-500">{s.attendance.absent}</p>
<p className="text-[11px] text-ink-500">Alpa/Izin</p>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Tren Siswa Aktif</CardTitle>
<span className="font-num text-[16px] font-bold text-brand-600">{s.counts.activeStudents}</span>
</CardHeader>
<CardBody className="pt-0">
<ActiveStudentsChart data={s.activeTrend} />
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Tahu Kursus Dari</CardTitle>
</CardHeader>
<CardBody className="pt-0">
<LeadSourceBar data={s.leadSources} />
</CardBody>
</Card>
<Link href="/students/new">
<Button className="w-full" size="lg">
Tambah Siswa Baru
<ArrowRight className="size-4" />
</Button>
</Link>
</aside>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { followUpSchema } from "@/lib/validators";
export type FollowUpState = { error?: string; ok?: boolean } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function updateFollowUpAction(_prev: FollowUpState, formData: FormData): Promise<FollowUpState> {
await requireUser();
const id = String(formData.get("id"));
const parsed = followUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
const existing = await prisma.followUp.findUnique({ where: { id }, include: { student: true } });
if (!existing) return { error: "Follow up tidak ditemukan." };
const statusChanged = existing.status !== d.status;
await prisma.followUp.update({
where: { id },
data: {
status: d.status,
picId: nullable(d.picId),
nextFollowUpAt: parseDate(d.nextFollowUpAt),
channel: d.channel ? d.channel : null,
lastContactedAt: d.note ? new Date() : existing.lastContactedAt,
},
});
if (d.note) {
await prisma.followUpActivity.create({
data: { followUpId: id, type: "wa", note: d.note, authorId: nullable(d.picId) ?? undefined },
});
}
// Jika status jadi ENROLLED → siswa otomatis jadi STUDENT
if (d.status === "ENROLLED" && existing.student.status === "CALON_STUDENT") {
await prisma.student.update({
where: { id: existing.studentId },
data: { status: "STUDENT", joinedAt: new Date() },
});
}
if (d.status === "LOST" && statusChanged) {
await prisma.followUp.update({ where: { id }, data: { nextFollowUpAt: null } });
}
revalidatePath("/follow-up");
revalidatePath("/dashboard");
return { ok: true };
}
export async function createFollowUpAction(_prev: FollowUpState, formData: FormData): Promise<FollowUpState> {
await requireUser();
const parsed = followUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
if (!d.studentId) return { error: "Pilih siswa terlebih dahulu." };
await prisma.followUp.create({
data: {
studentId: d.studentId,
status: d.status,
picId: nullable(d.picId),
nextFollowUpAt: parseDate(d.nextFollowUpAt),
channel: d.channel ? d.channel : null,
},
});
revalidatePath("/follow-up");
revalidatePath("/dashboard");
redirect("/follow-up");
}
+125
View File
@@ -0,0 +1,125 @@
import Link from "next/link";
import { BellRing, Plus } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatRelativeID } from "@/lib/utils";
import { FOLLOW_UP_STATUSES, FOLLOW_UP_META } from "@/lib/constants";
import { PageHeader } from "@/components/ui/page-header";
import { Card } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { FollowUpStatusForm } from "@/components/follow-up/status-form";
import { SourceChip, StatusBadge } from "@/components/students/badges";
import { cn } from "@/lib/utils";
export const metadata = { title: "Follow Up — Kodeva" };
export default async function FollowUpPage() {
await requireUser();
const followUps = await prisma.followUp.findMany({
include: { student: true, pic: true, activities: { orderBy: { createdAt: "desc" }, take: 1 } },
orderBy: { updatedAt: "desc" },
});
const columns = FOLLOW_UP_STATUSES.map((status) => ({
status,
items: followUps.filter((f) => f.status === status),
}));
const now = new Date();
const isDue = (d: Date | null) => d && d.getTime() <= now.getTime();
return (
<div>
<PageHeader
title="Follow Up Siswa"
description="Pantau dan pindahkan status follow up calon siswa."
/>
{/* Quick create */}
<Card className="mb-6 p-4">
<form action="/follow-up" className="hidden">
<input type="hidden" name="q" value="" />
</form>
<Link href="/students?status=CALON_STUDENT">
<p className="flex items-center gap-2 text-[13px] font-medium text-ink-500">
<Plus className="size-4 text-brand-500" />
Follow up baru dibuat otomatis saat menambah calon siswa. Lihat calon siswa
</p>
</Link>
</Card>
{/* Board */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{columns.map((col) => {
const meta = FOLLOW_UP_META[col.status];
return (
<div key={col.status} className="rounded-[18px] bg-canvas p-2.5">
<div className="mb-2 flex items-center justify-between px-1.5">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full" style={{ backgroundColor: meta.color }} />
<h3 className="text-[13px] font-semibold text-ink-900">{meta.label}</h3>
</div>
<span className="font-num rounded-full bg-surface px-2 py-0.5 text-[12px] font-bold text-ink-700">
{col.items.length}
</span>
</div>
<div className="space-y-2.5">
{col.items.length === 0 ? (
<div className="rounded-[12px] border border-dashed border-ink-300 py-6 text-center text-[12px] text-ink-500">
Kosong
</div>
) : (
col.items.map((f) => (
<div key={f.id} className="rounded-[14px] border border-ink-100 bg-surface p-3 shadow-[var(--shadow-xs)]">
<Link href={`/students/${f.student.id}`} className="flex items-center gap-2.5">
<Avatar src={f.student.photoUrl} name={f.student.fullName} size={34} />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold text-ink-900">{f.student.fullName}</p>
<p className="truncate text-[11px] text-ink-500">{f.student.phone}</p>
</div>
</Link>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<SourceChip source={f.student.leadSource} />
<StatusBadge status={f.student.status} />
</div>
<div className="mt-2.5 flex items-center justify-between gap-2">
<FollowUpStatusForm id={f.id} status={f.status} />
{f.nextFollowUpAt ? (
<span
className={cn(
"text-[11px] font-medium",
isDue(f.nextFollowUpAt) ? "text-danger-500" : "text-ink-500",
)}
>
{formatRelativeID(f.nextFollowUpAt)}
</span>
) : null}
</div>
{f.activities[0] ? (
<p className="mt-2 line-clamp-2 rounded-[10px] bg-canvas px-2 py-1.5 text-[11.5px] text-ink-500">
{f.activities[0].note}
</p>
) : null}
</div>
))
)}
</div>
</div>
);
})}
</div>
{followUps.length === 0 ? (
<div className="mt-6">
<EmptyState icon={<BellRing className="size-6" />} title="Belum ada follow up" description="Tambahkan calon siswa untuk mulai follow up." />
</div>
) : null}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { journalSchema } from "@/lib/validators";
export type JournalState = { error?: string } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function createJournalAction(_prev: JournalState, formData: FormData): Promise<JournalState> {
const user = await requireUser();
const payload = {
...Object.fromEntries(formData),
photoUrls: formData.getAll("photoUrls").map(String).filter(Boolean),
studentIds: formData.getAll("studentIds").map(String).filter(Boolean),
};
const parsed = journalSchema.safeParse(payload);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
try {
await prisma.journal.create({
data: {
classId: d.classId,
sessionId: nullable(d.sessionId),
title: d.title,
description: nullable(d.description),
activityDate: parseDate(d.activityDate) ?? new Date(),
tags: d.tags?.split(",").map((t) => t.trim()).filter(Boolean).join(",") ?? "",
authorId: user.userId,
photos: {
create: d.photoUrls.map((url, i) => ({ url, caption: `Dokumentasi ${i + 1}`, sortOrder: i })),
},
students: { create: d.studentIds.map((studentId) => ({ studentId })) },
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal membuat jurnal." };
}
revalidatePath("/journals");
revalidatePath("/dashboard");
redirect("/journals");
}
+34
View File
@@ -0,0 +1,34 @@
import { requireUser } from "@/lib/rbac";
import { prisma } from "@/lib/prisma";
import { PageHeader } from "@/components/ui/page-header";
import { JournalForm } from "@/components/journals/journal-form";
import { createJournalAction } from "../actions";
export const metadata = { title: "Buat Jurnal — Kodeva" };
export default async function NewJournalPage() {
await requireUser();
const [classes, students] = await Promise.all([
prisma.class.findMany({
where: { status: "ACTIVE" },
include: { sessions: { orderBy: { meetingNumber: "asc" } } },
orderBy: { name: "asc" },
}),
prisma.student.findMany({
where: { archivedAt: null },
include: { enrollments: { select: { classId: true } } },
orderBy: { fullName: "asc" },
}),
]);
return (
<div>
<PageHeader title="Buat Jurnal Aktivitas" description="Unggah foto dan ceritakan aktivitas kelas." />
<JournalForm
action={createJournalAction}
classes={classes.map((c) => ({ id: c.id, name: c.name, color: c.color, sessions: c.sessions }))}
students={students.map((s) => ({ id: s.id, fullName: s.fullName, enrolledClassIds: s.enrollments.map((e) => e.classId) }))}
/>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from "next/link";
import { Camera, NotebookPen, Plus } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatRelativeID } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card, CardBody } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { Chip } from "@/components/ui/badge";
export const metadata = { title: "Jurnal Aktivitas — Kodeva" };
export default async function JournalsPage() {
await requireUser();
const journals = await prisma.journal.findMany({
include: {
class: true,
author: true,
photos: { orderBy: { sortOrder: "asc" }, take: 1 },
students: { select: { student: { select: { fullName: true, photoUrl: true } } } },
},
orderBy: { activityDate: "desc" },
});
return (
<div>
<PageHeader
title="Jurnal Aktivitas"
description="Dokumentasi foto dan aktivitas di kelas."
actions={
<Link href="/journals/new">
<Button>
<Plus className="size-4" /> Buat Jurnal
</Button>
</Link>
}
/>
{journals.length === 0 ? (
<EmptyState icon={<NotebookPen className="size-6" />} title="Belum ada jurnal aktivitas" description="Abadikan momen belajar di kelas dengan foto." />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{journals.map((j) => (
<Card key={j.id} className="group overflow-hidden transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-md)]">
<div className="relative">
{j.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={j.photos[0].url} alt={j.title} className="h-44 w-full object-cover" />
) : (
<div className="grad-pink flex h-44 items-center justify-center text-white">
<Camera className="size-8 opacity-80" />
</div>
)}
<span
className="absolute left-3 top-3 rounded-full px-2.5 py-1 text-[11px] font-semibold text-white"
style={{ backgroundColor: j.class.color ?? "#7C5CFC" }}
>
{j.class.name}
</span>
</div>
<CardBody className="pt-4">
<h3 className="text-[15px] font-semibold text-ink-900 group-hover:text-brand-600">{j.title}</h3>
<p className="mt-1 line-clamp-2 text-[13px] text-ink-500">{j.description}</p>
{j.tags ? (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{j.tags.split(",").filter(Boolean).map((t) => (
<Chip key={t}>#{t.trim()}</Chip>
))}
</div>
) : null}
<div className="mt-3 flex items-center justify-between border-t border-ink-100 pt-3">
<div className="flex items-center gap-2">
<Avatar src={j.author.avatarUrl} name={j.author.name} size={24} />
<span className="text-[12px] text-ink-500">{j.author.name}</span>
</div>
<div className="flex items-center gap-2">
{j.students.length > 0 ? (
<span className="flex -space-x-2">
{j.students.slice(0, 3).map((s) => (
<Avatar key={s.student.fullName} src={s.student.photoUrl} name={s.student.fullName} size={22} className="ring-2 ring-surface" />
))}
</span>
) : null}
<span className="text-[12px] text-ink-500">{formatRelativeID(j.activityDate)}</span>
</div>
</div>
</CardBody>
</Card>
))}
</div>
)}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { requireUser } from "@/lib/rbac";
import { prisma } from "@/lib/prisma";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await requireUser();
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
const dbUser = await prisma.user.findUnique({ where: { id: session.userId } });
const followUpCount = await prisma.followUp.count({
where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: endOfToday } },
});
const user = {
name: dbUser?.name ?? session.name,
email: dbUser?.email ?? session.email,
role: session.role,
avatarUrl: dbUser?.avatarUrl ?? null,
};
return (
<div className="min-h-screen">
<Sidebar followUpCount={followUpCount} user={user} />
<div className="lg:pl-[248px]">
<Topbar user={user} />
<main className="mx-auto w-full max-w-[1400px] px-4 py-6 sm:px-6">{children}</main>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/rbac";
import { paymentSchema } from "@/lib/validators";
import { calcAmount, getPricePerMeeting } from "@/lib/prorata";
export type PaymentState = { error?: string } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
async function nextInvoiceNumber() {
const year = new Date().getFullYear();
const last = await prisma.payment.findFirst({
where: { invoiceNumber: { startsWith: `INV-${year}-` } },
orderBy: { invoiceNumber: "desc" },
});
const lastSeq = last ? parseInt(last.invoiceNumber.split("-")[2] ?? "0", 10) : 0;
return `INV-${year}-${String(lastSeq + 1).padStart(4, "0")}`;
}
export async function createPaymentAction(_prev: PaymentState, formData: FormData): Promise<PaymentState> {
await requireAdmin();
const parsed = paymentSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
const cls = await prisma.class.findUnique({ where: { id: d.classId } });
if (!cls) return { error: "Kelas tidak ditemukan." };
const ppm = getPricePerMeeting(cls);
if (ppm <= 0) return { error: "Harga per pertemuan kelas ini belum dikonfigurasi." };
const amount = calcAmount(d.meetingsPaid, ppm);
try {
await prisma.payment.create({
data: {
invoiceNumber: await nextInvoiceNumber(),
studentId: d.studentId,
classId: d.classId,
meetingsPaid: d.meetingsPaid,
pricePerMeeting: ppm,
amount,
method: d.method,
status: d.status,
paidAt: parseDate(d.paidAt) ?? new Date(),
note: nullable(d.note),
proofUrl: nullable(d.proofUrl),
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal membuat pembayaran." };
}
revalidatePath("/payments");
revalidatePath(`/students/${d.studentId}`);
revalidatePath("/dashboard");
redirect("/payments");
}
export async function refundPaymentAction(formData: FormData) {
await requireAdmin();
const id = String(formData.get("id"));
await prisma.payment.update({ where: { id }, data: { status: "REFUNDED" } });
revalidatePath("/payments");
redirect("/payments");
}
+22
View File
@@ -0,0 +1,22 @@
import { requireAdmin } from "@/lib/rbac";
import { getClassOptions, getStudentOptions } from "@/lib/queries";
import { PageHeader } from "@/components/ui/page-header";
import { PaymentForm } from "@/components/payments/payment-form";
import { createPaymentAction } from "../actions";
export const metadata = { title: "Catat Pembayaran — Kodeva" };
type SP = Promise<{ studentId?: string }>;
export default async function NewPaymentPage({ searchParams }: { searchParams: SP }) {
await requireAdmin();
const sp = await searchParams;
const [students, classes] = await Promise.all([getStudentOptions(), getClassOptions()]);
return (
<div>
<PageHeader title="Catat Pembayaran" description="Pembayaran dihitung prorata per pertemuan." />
<PaymentForm action={createPaymentAction} students={students} classes={classes} defaultStudentId={sp.studentId} />
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import Link from "next/link";
import { Plus, Undo2, Wallet } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID, formatIDR } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { MethodChip, PaymentStatusBadge } from "@/components/students/badges";
import { refundPaymentAction } from "./actions";
export const metadata = { title: "Pembayaran — Kodeva" };
type SP = Promise<{ studentId?: string; method?: string; status?: string }>;
export default async function PaymentsPage({ searchParams }: { searchParams: SP }) {
const user = await requireUser();
const sp = await searchParams;
const payments = await prisma.payment.findMany({
where: {
...(sp.studentId ? { studentId: sp.studentId } : {}),
...(sp.method ? { method: sp.method } : {}),
...(sp.status ? { status: sp.status } : {}),
},
include: { student: true, class: true },
orderBy: { paidAt: "desc" },
});
const totalPaid = payments.filter((p) => p.status === "PAID").reduce((a, p) => a + p.amount, 0);
return (
<div>
<PageHeader
title="Pembayaran"
description={`Total diterima: ${formatIDR(totalPaid)}`}
actions={
<Link href="/payments/new">
<Button>
<Plus className="size-4" /> Catat Pembayaran
</Button>
</Link>
}
/>
{payments.length === 0 ? (
<EmptyState icon={<Wallet className="size-6" />} title="Belum ada pembayaran" description="Catat pembayaran prorata pertama siswa." />
) : (
<Card className="overflow-hidden">
<div className="hidden md:block">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-100 text-[12px] uppercase tracking-wide text-ink-500">
<th className="px-5 py-3 font-semibold">Invoice</th>
<th className="px-3 py-3 font-semibold">Siswa</th>
<th className="px-3 py-3 font-semibold">Kelas</th>
<th className="px-3 py-3 font-semibold">Detail</th>
<th className="px-3 py-3 font-semibold">Nominal</th>
<th className="px-3 py-3 font-semibold">Metode</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{payments.map((p) => (
<tr key={p.id} className="transition-colors hover:bg-brand-50/40">
<td className="px-5 py-3">
<span className="font-mono text-[12px] text-ink-500">{p.invoiceNumber}</span>
<p className="text-[11.5px] text-ink-500">{formatDateID(p.paidAt)}</p>
</td>
<td className="px-3 py-3">
<Link href={`/students/${p.studentId}`} className="flex items-center gap-2.5">
<Avatar src={p.student.photoUrl} name={p.student.fullName} size={30} />
<span className="font-medium text-ink-900">{p.student.fullName}</span>
</Link>
</td>
<td className="px-3 py-3 text-ink-700">{p.class.name}</td>
<td className="px-3 py-3 text-[12.5px] text-ink-500">
{p.meetingsPaid} × {formatIDR(p.pricePerMeeting)}
</td>
<td className="px-3 py-3 font-num font-semibold text-ink-900">{formatIDR(p.amount)}</td>
<td className="px-3 py-3">
<MethodChip method={p.method} />
</td>
<td className="px-3 py-3">
<PaymentStatusBadge status={p.status} />
</td>
<td className="px-5 py-3 text-right">
{user.role === "ADMIN" && p.status !== "REFUNDED" ? (
<form action={refundPaymentAction} className="inline">
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="inline-flex items-center gap-1 text-[12px] font-medium text-danger-500 hover:underline">
<Undo2 className="size-3.5" /> Refund
</button>
</form>
) : (
<span className="text-[12px] text-ink-500">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* mobile cards */}
<div className="divide-y divide-ink-100 md:hidden">
{payments.map((p) => (
<div key={p.id} className="p-4">
<div className="flex items-center justify-between">
<span className="font-mono text-[12px] text-ink-500">{p.invoiceNumber}</span>
<PaymentStatusBadge status={p.status} />
</div>
<p className="mt-1.5 font-semibold text-ink-900">{p.student.fullName}</p>
<p className="text-[12.5px] text-ink-500">{p.class.name}</p>
<div className="mt-2 flex items-center justify-between">
<p className="font-num text-[15px] font-bold text-ink-900">{formatIDR(p.amount)}</p>
<MethodChip method={p.method} />
</div>
</div>
))}
</div>
</Card>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { placementSchema } from "@/lib/validators";
import { levelFromScore } from "@/lib/prorata";
import { PLACEMENT_CATEGORIES } from "@/lib/constants";
export type PlacementState = { error?: string } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function createPlacementAction(_prev: PlacementState, formData: FormData): Promise<PlacementState> {
const user = await requireUser();
const rest: Record<string, FormDataEntryValue> = {};
const scores: Record<string, number> = {};
for (const [k, v] of formData.entries()) {
if (k.startsWith("scores.")) scores[k.slice(7)] = Number(v);
else rest[k] = v;
}
const parsed = placementSchema.safeParse({ ...rest, scores });
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
const values = PLACEMENT_CATEGORIES.map((c) => d.scores[c]).filter((n) => Number.isFinite(n)) as number[];
const totalScore = values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
const levelResult = levelFromScore(totalScore);
try {
await prisma.placementTest.create({
data: {
studentId: d.studentId,
testDate: parseDate(d.testDate) ?? new Date(),
levelResult,
recommendedClassId: nullable(d.recommendedClassId),
totalScore,
examinerId: nullable(d.examinerId) ?? user.userId,
notes: nullable(d.notes),
scores: {
create: PLACEMENT_CATEGORIES.map((category) => ({ category, score: d.scores[category] ?? 0 })),
},
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal menyimpan placement test." };
}
revalidatePath("/placements");
revalidatePath(`/students/${d.studentId}`);
redirect("/placements");
}
+24
View File
@@ -0,0 +1,24 @@
import { requireUser } from "@/lib/rbac";
import { prisma } from "@/lib/prisma";
import { getClassOptions, getStudentOptions } from "@/lib/queries";
import { PageHeader } from "@/components/ui/page-header";
import { PlacementForm } from "@/components/placements/placement-form";
import { createPlacementAction } from "../actions";
export const metadata = { title: "Input Placement Test — Kodeva" };
export default async function NewPlacementPage() {
await requireUser();
const [students, classes, examiners] = await Promise.all([
getStudentOptions(),
getClassOptions(),
prisma.user.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
]);
return (
<div>
<PageHeader title="Input Hasil Placement Test" description="Catat skor dan rekomendasi level siswa." />
<PlacementForm action={createPlacementAction} students={students} classes={classes} examiners={examiners} />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import Link from "next/link";
import { Plus, Target } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card, CardBody } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { LevelBadge } from "@/components/students/badges";
export const metadata = { title: "Placement Test — Kodeva" };
export default async function PlacementsPage() {
await requireUser();
const tests = await prisma.placementTest.findMany({
include: { student: true, examiner: true },
orderBy: { testDate: "desc" },
});
const classes = await prisma.class.findMany({ select: { id: true, name: true } });
return (
<div>
<PageHeader
title="Placement Test"
description="Hasil tes penempatan untuk menentukan level siswa."
actions={
<Link href="/placements/new">
<Button>
<Plus className="size-4" /> Input Hasil Tes
</Button>
</Link>
}
/>
{tests.length === 0 ? (
<EmptyState icon={<Target className="size-6" />} title="Belum ada hasil placement test" />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{tests.map((t) => (
<Link key={t.id} href={`/students/${t.studentId}?tab=placement`}>
<Card className="transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-md)]">
<CardBody className="pt-5">
<div className="flex items-center gap-3">
<Avatar src={t.student.photoUrl} name={t.student.fullName} size={44} />
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-semibold text-ink-900">{t.student.fullName}</p>
<p className="text-[12px] text-ink-500">{formatDateID(t.testDate)}</p>
</div>
<div className="text-right">
<p className="font-num text-[22px] font-bold text-ink-900">{t.totalScore}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<LevelBadge level={t.levelResult} />
<span className="text-[12px] text-ink-500">
Rekomendasi: {classes.find((c) => c.id === t.recommendedClassId)?.name ?? "-"}
</span>
</div>
</CardBody>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { reportCardSchema } from "@/lib/validators";
import { gradeFromScore } from "@/lib/prorata";
import { REPORT_ASPECTS } from "@/lib/constants";
export type ReportState = { error?: string } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
export async function createReportCardAction(_prev: ReportState, formData: FormData): Promise<ReportState> {
const user = await requireUser();
const rest: Record<string, FormDataEntryValue> = {};
const scores: Record<string, number> = {};
for (const [k, v] of formData.entries()) {
if (k.startsWith("scores.")) scores[k.slice(7)] = Number(v);
else rest[k] = v;
}
const parsed = reportCardSchema.safeParse({ ...rest, scores });
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
const values = REPORT_ASPECTS.map((a) => d.scores[a]).filter((n) => Number.isFinite(n)) as number[];
const finalScore = values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : null;
const grade = finalScore != null ? gradeFromScore(finalScore) : null;
try {
await prisma.reportCard.create({
data: {
studentId: d.studentId,
classId: d.classId,
period: d.period,
teacherId: user.userId,
teacherNotes: nullable(d.teacherNotes),
attendanceSummary: nullable(d.attendanceSummary),
finalScore,
grade,
scores: {
create: REPORT_ASPECTS.map((aspect) => ({
aspect,
score: d.scores[aspect] ?? 0,
})),
},
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal membuat raport." };
}
revalidatePath("/report-cards");
revalidatePath(`/students/${d.studentId}`);
redirect("/report-cards");
}
export async function publishReportCardAction(formData: FormData) {
await requireUser();
const id = String(formData.get("id"));
await prisma.reportCard.update({ where: { id }, data: { publishedAt: new Date() } });
revalidatePath("/report-cards");
redirect("/report-cards");
}
+19
View File
@@ -0,0 +1,19 @@
import { requireUser } from "@/lib/rbac";
import { getClassOptions, getStudentOptions } from "@/lib/queries";
import { PageHeader } from "@/components/ui/page-header";
import { ReportForm } from "@/components/reports/report-form";
import { createReportCardAction } from "../actions";
export const metadata = { title: "Buat Raport — Kodeva" };
export default async function NewReportCardPage() {
await requireUser();
const [students, classes] = await Promise.all([getStudentOptions(), getClassOptions()]);
return (
<div>
<PageHeader title="Buat Raport" description="Nilai aspek perkembangan siswa dan kesimpulan guru." />
<ReportForm action={createReportCardAction} students={students} classes={classes} />
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import { FileText, Plus, Send } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Badge } from "@/components/ui/badge";
import { LevelBadge } from "@/components/students/badges";
import { publishReportCardAction } from "./actions";
export const metadata = { title: "Raport — Kodeva" };
export default async function ReportCardsPage() {
await requireUser();
const reports = await prisma.reportCard.findMany({
include: { student: true, class: true, teacher: true },
orderBy: { createdAt: "desc" },
});
return (
<div>
<PageHeader
title="Raport"
description="Penilaian perkembangan siswa per periode."
actions={
<Link href="/report-cards/new">
<Button>
<Plus className="size-4" /> Buat Raport
</Button>
</Link>
}
/>
{reports.length === 0 ? (
<EmptyState icon={<FileText className="size-6" />} title="Belum ada raport" description="Buat raport untuk siswa di kelasmu." />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{reports.map((r) => (
<Card key={r.id} className="overflow-hidden">
<div className="grad-primary px-5 py-4 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-[12px] text-white/80">{r.period}</p>
<h3 className="text-[17px] font-bold">{r.student.fullName}</h3>
</div>
<div className="text-center">
<p className="font-num text-[26px] font-bold leading-none">{r.finalScore ?? "-"}</p>
{r.grade ? (
<span className="mt-1 inline-block rounded-full bg-white/20 px-2 py-0.5 text-[11px] font-bold">Grade {r.grade}</span>
) : null}
</div>
</div>
</div>
<div className="p-4">
<div className="flex items-center justify-between text-[13px]">
<span className="flex items-center gap-1.5 text-ink-700">
<span className="size-2 rounded-full" style={{ backgroundColor: r.class.color ?? "#7C5CFC" }} />
{r.class.name}
</span>
<LevelBadge level={r.class.level} />
</div>
<p className="mt-2 text-[12px] text-ink-500">Oleh {r.teacher.name} · {formatDateID(r.createdAt)}</p>
<div className="mt-3 flex items-center justify-between border-t border-ink-100 pt-3">
{r.publishedAt ? (
<Badge bg="#E4F8F1" fg="#0A7A58">Dipublikasikan</Badge>
) : (
<Badge bg="#FFF4E0" fg="#9A6100">Draft</Badge>
)}
<div className="flex items-center gap-2">
<Link href={`/students/${r.studentId}?tab=raport`} className="text-[12.5px] font-semibold text-brand-600 hover:underline">
Lihat
</Link>
{!r.publishedAt ? (
<form action={publishReportCardAction}>
<input type="hidden" name="id" value={r.id} />
<button type="submit" className="inline-flex items-center gap-1 text-[12.5px] font-semibold text-mint-500 hover:underline">
<Send className="size-3.5" /> Publish
</button>
</form>
) : null}
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { Building2, Megaphone, ShieldCheck, Users } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/rbac";
import { PageHeader } from "@/components/ui/page-header";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { Badge, Chip } from "@/components/ui/badge";
import { LEAD_SOURCE_META } from "@/lib/constants";
export const metadata = { title: "Pengaturan — Kodeva" };
export default async function SettingsPage() {
await requireAdmin();
const [academySetting, leadSourcesSetting, users] = await Promise.all([
prisma.setting.findUnique({ where: { key: "academy" } }),
prisma.setting.findUnique({ where: { key: "leadSources" } }),
prisma.user.findMany({ orderBy: { name: "asc" } }),
]);
const academy = academySetting ? (JSON.parse(academySetting.value) as { name: string; address: string; phone: string; email: string }) : null;
const leadSources = leadSourcesSetting
? (JSON.parse(leadSourcesSetting.value) as { value: string; label: string; color: string; active: boolean }[])
: Object.entries(LEAD_SOURCE_META).map(([value, m]) => ({ value, label: m.label, color: m.color, active: true }));
return (
<div>
<PageHeader title="Pengaturan" description="Konfigurasi akademi, sumber lead, dan pengguna." />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4 text-brand-500" /> Info Akademi
</CardTitle>
</CardHeader>
<CardBody className="space-y-2 text-[13.5px]">
<Info label="Nama" value={academy?.name ?? "Kodeva Academy"} />
<Info label="Alamat" value={academy?.address ?? "-"} />
<Info label="Telepon" value={academy?.phone ?? "-"} />
<Info label="Email" value={academy?.email ?? "-"} />
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Megaphone className="size-4 text-coral-500" /> Sumber Lead
</CardTitle>
</CardHeader>
<CardBody>
<div className="space-y-2.5">
{leadSources.map((s) => (
<div key={s.value} className="flex items-center justify-between rounded-[12px] border border-ink-100 px-3 py-2.5">
<Chip color={s.color}>{s.label}</Chip>
<Badge bg={s.active ? "#E4F8F1" : "#EEF1F7"} fg={s.active ? "#0A7A58" : "#6B7590"}>
{s.active ? "Aktif" : "Nonaktif"}
</Badge>
</div>
))}
</div>
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4 text-mint-500" /> Pengguna
</CardTitle>
<span className="text-[12px] text-ink-500">{users.length} akun</span>
</CardHeader>
<CardBody>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{users.map((u) => (
<div key={u.id} className="flex items-center gap-3 rounded-[14px] border border-ink-100 px-3 py-2.5">
<Avatar src={u.avatarUrl} name={u.name} size={38} />
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-semibold text-ink-900">{u.name}</p>
<p className="truncate text-[12px] text-ink-500">{u.email}</p>
</div>
<Badge bg={u.role === "ADMIN" ? "#F1ECFE" : "#E3F3FF"} fg={u.role === "ADMIN" ? "#5B34C9" : "#0B6FB8"}>
{u.role === "ADMIN" ? "Admin" : "Teacher"}
</Badge>
</div>
))}
</div>
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardBody className="flex items-center gap-3">
<ShieldCheck className="size-5 shrink-0 text-brand-500" />
<p className="text-[13px] text-ink-500">
Manajemen pengguna, sumber lead, dan data akademi tersedia untuk role <b className="text-ink-900">Admin</b>.
Teacher hanya bisa melihat data dan mengisi kehadiran, jurnal, serta raport.
</p>
</CardBody>
</Card>
</div>
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 border-b border-ink-100 pb-2.5 last:border-0">
<span className="shrink-0 text-ink-500">{label}</span>
<span className="text-right font-medium text-ink-900">{value}</span>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { getClassOptions } from "@/lib/queries";
import { PageHeader } from "@/components/ui/page-header";
import { StudentForm } from "@/components/students/student-form";
import { updateStudentAction } from "../../actions";
export const metadata = { title: "Edit Siswa — Kodeva" };
export default async function EditStudentPage({ params }: { params: Promise<{ id: string }> }) {
await requireUser();
const { id } = await params;
const [student, classes] = await Promise.all([
prisma.student.findUnique({ where: { id }, include: { enrollments: true } }),
getClassOptions(),
]);
if (!student) notFound();
const isoDate = student.birthDate ? new Date(student.birthDate).toISOString().slice(0, 10) : "";
return (
<div>
<PageHeader title={`Edit ${student.fullName}`} description="Perbarui data siswa, status, dan kelas." />
<StudentForm
action={updateStudentAction}
classes={classes}
mode="edit"
initial={{
id: student.id,
fullName: student.fullName,
phone: student.phone,
email: student.email,
photoUrl: student.photoUrl,
parentName: student.parentName,
parentPhone: student.parentPhone,
gender: student.gender,
birthDate: isoDate,
address: student.address,
leadSource: student.leadSource,
status: student.status,
notes: student.notes,
classIds: student.enrollments.map((e) => e.classId),
}}
/>
</div>
);
}
+504
View File
@@ -0,0 +1,504 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
CalendarCheck,
FileText,
GraduationCap,
LayoutList,
Mail,
MessageCircle,
NotebookPen,
Pencil,
Phone,
Target,
UserRound,
Wallet,
} from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID, formatIDR, formatRelativeID } from "@/lib/utils";
import { getPricePerMeeting, gradeFromScore } from "@/lib/prorata";
import { LEAD_SOURCE_META } from "@/lib/constants";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { archiveStudentAction } from "../actions";
import { LevelBadge, MethodChip, PaymentStatusBadge, SourceChip, StatusBadge } from "@/components/students/badges";
import { cn } from "@/lib/utils";
export const metadata = { title: "Detail Siswa — Kodeva" };
const TABS = [
{ key: "ringkasan", label: "Ringkasan", icon: LayoutList },
{ key: "kelas", label: "Kelas & Kehadiran", icon: CalendarCheck },
{ key: "pembayaran", label: "Pembayaran", icon: Wallet },
{ key: "placement", label: "Placement Test", icon: Target },
{ key: "raport", label: "Raport", icon: FileText },
{ key: "jurnal", label: "Jurnal", icon: NotebookPen },
];
function waLink(phone: string) {
return `https://wa.me/${phone.replace(/[^0-9]/g, "").replace(/^0/, "62")}`;
}
export default async function StudentDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ tab?: string }>;
}) {
const user = await requireUser();
const { id } = await params;
const sp = await searchParams;
const tab = TABS.some((t) => t.key === sp.tab) ? sp.tab! : "ringkasan";
const student = await prisma.student.findUnique({
where: { id },
include: {
enrollments: { include: { class: { include: { teacher: true } } } },
payments: { include: { class: true }, orderBy: { paidAt: "desc" } },
attendances: { include: { session: true }, orderBy: { session: { date: "desc" } } },
placementTests: { include: { scores: true, examiner: true }, orderBy: { testDate: "desc" } },
reportCards: { include: { scores: true, teacher: true, class: true }, orderBy: { createdAt: "desc" } },
journalStudents: {
include: { journal: { include: { class: true, photos: true } } },
orderBy: { journal: { activityDate: "desc" } },
},
followUps: { include: { activities: true, pic: true }, orderBy: { updatedAt: "desc" }, take: 1 },
},
});
if (!student) notFound();
// Referensi kelas untuk rekomendasi placement
const classes = await prisma.class.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } });
// Prorata per kelas
const prorataByClass = new Map<string, { paidMeetings: number; usedMeetings: number; ppm: number; className: string }>();
for (const en of student.enrollments) {
const paid = student.payments
.filter((p) => p.classId === en.classId && p.status !== "REFUNDED")
.reduce((a, p) => a + p.meetingsPaid, 0);
const used = student.attendances.filter(
(a) => a.session.classId === en.classId && (a.status === "PRESENT" || a.status === "LATE"),
).length;
prorataByClass.set(en.classId, {
paidMeetings: paid,
usedMeetings: used,
ppm: getPricePerMeeting(en.class),
className: en.class.name,
});
}
const latestPlacement = student.placementTests[0];
const attendanceCount = student.attendances.length;
const presentCount = student.attendances.filter((a) => a.status === "PRESENT" || a.status === "LATE").length;
const attendanceRate = attendanceCount ? Math.round((presentCount / attendanceCount) * 100) : 0;
const sourceMeta = LEAD_SOURCE_META[student.leadSource];
return (
<div className="space-y-6">
{/* Header profil */}
<div className="animate-fade-up overflow-hidden rounded-[24px] border border-ink-100 bg-surface shadow-[var(--shadow-sm)]">
<div className="grad-primary h-24" />
<div className="px-5 pb-5 sm:px-7">
<div className="-mt-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex items-end gap-4">
<Avatar src={student.photoUrl} name={student.fullName} size={84} ring className="border-4 border-surface" />
<div className="pb-0.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-[22px] font-extrabold text-ink-900">{student.fullName}</h1>
<StatusBadge status={student.status} />
{latestPlacement ? <LevelBadge level={latestPlacement.levelResult} /> : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12.5px] text-ink-500">
<span className="flex items-center gap-1.5">
<Phone className="size-3.5" /> {student.phone}
</span>
{student.email ? (
<span className="flex items-center gap-1.5">
<Mail className="size-3.5" /> {student.email}
</span>
) : null}
<span className="flex items-center gap-1.5">
<UserRound className="size-3.5" /> Ortu: {student.parentName ?? "-"}
</span>
<SourceChip source={student.leadSource} />
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<a href={waLink(student.phone)} target="_blank" rel="noreferrer">
<Button variant="secondary">
<MessageCircle className="size-4" /> WhatsApp
</Button>
</a>
<a href={`tel:${student.phone}`}>
<Button variant="outline">
<Phone className="size-4" /> Telepon
</Button>
</a>
<Link href={`/students/${student.id}/edit`}>
<Button variant="outline">
<Pencil className="size-4" /> Edit
</Button>
</Link>
{user.role === "ADMIN" ? (
<form action={archiveStudentAction}>
<input type="hidden" name="id" value={student.id} />
<Button variant="ghost" className="text-danger-500 hover:bg-danger-100">
Arsipkan
</Button>
</form>
) : null}
</div>
</div>
{/* Stat mini */}
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{ label: "Kehadiran", value: `${attendanceRate}%`, icon: CalendarCheck, color: "#12B886" },
{ label: "Pertemuan hadir", value: `${presentCount}/${attendanceCount}`, icon: GraduationCap, color: "#7C5CFC" },
{ label: "Kelas aktif", value: student.enrollments.filter((e) => e.status === "ACTIVE").length, icon: LayoutList, color: "#2AA9FF" },
{ label: "Total pembayaran", value: formatIDR(student.payments.filter((p) => p.status === "PAID").reduce((a, p) => a + p.amount, 0)), icon: Wallet, color: "#FFB020" },
].map((s) => (
<div key={s.label} className="flex items-center gap-3 rounded-[14px] border border-ink-100 bg-canvas px-3 py-2.5">
<span className="flex size-9 items-center justify-center rounded-[10px]" style={{ backgroundColor: `${s.color}1a`, color: s.color }}>
<s.icon className="size-[18px]" />
</span>
<div className="min-w-0">
<p className="truncate font-num text-[15px] font-bold text-ink-900">{s.value}</p>
<p className="truncate text-[11px] text-ink-500">{s.label}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Tab nav */}
<div className="flex gap-1 overflow-x-auto rounded-[16px] border border-ink-100 bg-surface p-1.5">
{TABS.map((t) => {
const Icon = t.icon;
const active = tab === t.key;
return (
<Link
key={t.key}
href={`/students/${student.id}?tab=${t.key}`}
className={cn(
"flex shrink-0 items-center gap-2 rounded-[12px] px-3.5 py-2 text-[13px] font-medium transition-colors",
active ? "grad-primary text-white" : "text-ink-700 hover:bg-brand-50 hover:text-brand-700",
)}
>
<Icon className="size-4" />
{t.label}
</Link>
);
})}
</div>
{/* ── TAB: RINGKASAN ─────────────────────────────── */}
{tab === "ringkasan" ? (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Informasi</CardTitle>
</CardHeader>
<CardBody className="space-y-3 text-[13px]">
<Info label="No. telepon" value={student.phone} />
<Info label="Email" value={student.email ?? "-"} />
<Info label="Orang tua" value={student.parentName ?? "-"} />
<Info label="Telepon orang tua" value={student.parentPhone ?? "-"} />
<Info label="Alamat" value={student.address ?? "-"} />
<Info label="Tahu dari" value={sourceMeta?.label ?? student.leadSource} />
<Info label="Bergabung" value={formatDateID(student.joinedAt ?? student.createdAt)} />
{student.notes ? (
<div className="rounded-[12px] bg-amber-100 px-3 py-2.5">
<p className="text-[11px] font-semibold uppercase text-amber-500">Catatan</p>
<p className="mt-0.5 text-ink-700">{student.notes}</p>
</div>
) : null}
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Kelas & Kuota Sesi</CardTitle>
<Link href={`/students/${student.id}?tab=kelas`} className="text-[13px] font-semibold text-brand-600 hover:underline">
Detail
</Link>
</CardHeader>
<CardBody className="space-y-3 pt-0">
{student.enrollments.length === 0 ? (
<EmptyState title="Belum terdaftar di kelas mana pun" description="Tambahkan kelas lewat tombol Edit." />
) : (
student.enrollments.map((en) => {
const p = prorataByClass.get(en.classId)!;
const remaining = Math.max(0, p.paidMeetings - p.usedMeetings);
const progress = p.paidMeetings ? Math.min(100, Math.round((p.usedMeetings / p.paidMeetings) * 100)) : 0;
return (
<div key={en.id} className="rounded-[16px] border border-ink-100 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="size-9 rounded-[10px]" style={{ backgroundColor: `${en.class.color ?? "#7C5CFC"}1a` }} />
<div>
<p className="text-[14px] font-semibold text-ink-900">{en.class.name}</p>
<p className="text-[12px] text-ink-500">{en.class.schedule ?? "-"}</p>
</div>
</div>
<StatusBadge status={en.status === "ACTIVE" ? "STUDENT" : "EX_STUDENT"} />
</div>
<div className="mt-3">
<div className="mb-1 flex justify-between text-[12px] text-ink-500">
<span>
Terpakai <b className="text-ink-900">{p.usedMeetings}</b> / <b className="text-ink-900">{p.paidMeetings}</b> sesi
</span>
<span>Sisa <b className="text-ink-900">{remaining}</b> sesi</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-ink-100">
<div className="h-full rounded-full" style={{ width: `${progress}%`, backgroundColor: en.class.color ?? "#7C5CFC" }} />
</div>
<p className="mt-2 text-[12px] text-ink-500">
Nilai sisa: <span className="font-semibold text-ink-900">{formatIDR(remaining * p.ppm)}</span>
{" "}· {formatIDR(p.ppm)}/pertemuan
</p>
</div>
</div>
);
})
)}
</CardBody>
</Card>
</div>
) : null}
{/* ── TAB: KELAS & KEHADIRAN ─────────────────────── */}
{tab === "kelas" ? (
<Card>
<CardHeader>
<CardTitle>Riwayat Kehadiran</CardTitle>
<span className="text-[12px] text-ink-500">{attendanceCount} catatan</span>
</CardHeader>
<CardBody className="pt-0">
{student.attendances.length === 0 ? (
<EmptyState icon={<CalendarCheck className="size-6" />} title="Belum ada catatan kehadiran" />
) : (
<div className="divide-y divide-ink-100">
{student.attendances.slice(0, 30).map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5">
<span className="font-num text-[13px] font-semibold text-ink-700">
#{a.session.meetingNumber}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-ink-900">{a.session.topic ?? "Pertemuan"}</p>
<p className="text-[12px] text-ink-500">{formatDateID(a.session.date, "EEE, d MMM yyyy")}</p>
</div>
{a.note ? <span className="text-[12px] text-ink-500">{a.note}</span> : null}
<AttendancePill status={a.status} />
</div>
))}
</div>
)}
</CardBody>
</Card>
) : null}
{/* ── TAB: PEMBAYARAN ────────────────────────────── */}
{tab === "pembayaran" ? (
<Card>
<CardHeader>
<CardTitle>Riwayat Pembayaran</CardTitle>
<Link href={`/payments/new?studentId=${student.id}`}>
<Button size="sm">
<Wallet className="size-4" /> Catat Pembayaran
</Button>
</Link>
</CardHeader>
<CardBody className="pt-0">
{student.payments.length === 0 ? (
<EmptyState icon={<Wallet className="size-6" />} title="Belum ada pembayaran" />
) : (
<div className="space-y-3">
{student.payments.map((p) => (
<div key={p.id} className="flex flex-wrap items-center gap-3 rounded-[14px] border border-ink-100 p-3.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[12px] text-ink-500">{p.invoiceNumber}</span>
<PaymentStatusBadge status={p.status} />
</div>
<p className="mt-1 text-[14px] font-semibold text-ink-900">
{formatIDR(p.amount)}{" "}
<span className="text-[12px] font-normal text-ink-500">
= {p.meetingsPaid} pertemuan × {formatIDR(p.pricePerMeeting)}
</span>
</p>
<p className="text-[12px] text-ink-500">
{p.class.name} · {formatDateID(p.paidAt)}
</p>
</div>
<MethodChip method={p.method} />
</div>
))}
</div>
)}
</CardBody>
</Card>
) : null}
{/* ── TAB: PLACEMENT TEST ────────────────────────── */}
{tab === "placement" ? (
<div className="space-y-4">
{student.placementTests.length === 0 ? (
<EmptyState icon={<Target className="size-6" />} title="Belum ada hasil placement test" description="Input hasil tes penempatan di menu Placement Test." />
) : (
student.placementTests.map((pt) => (
<Card key={pt.id}>
<CardHeader>
<div className="flex items-center gap-3">
<span className="grad-primary flex size-10 items-center justify-center rounded-[12px] text-white">
<Target className="size-5" />
</span>
<div>
<CardTitle>Hasil Placement Test</CardTitle>
<p className="text-[12px] text-ink-500">
{formatDateID(pt.testDate)} · Pemeriksa {pt.examiner?.name ?? "-"}
</p>
</div>
</div>
<div className="text-right">
<p className="font-num text-[26px] font-bold text-ink-900">{pt.totalScore}</p>
<LevelBadge level={pt.levelResult} />
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-2.5">
{pt.scores.map((s) => (
<div key={s.id}>
<div className="mb-1 flex justify-between text-[12.5px]">
<span className="font-medium text-ink-700">{s.category}</span>
<span className="font-num font-semibold text-ink-900">{s.score}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-ink-100">
<div className="grad-primary h-full rounded-full" style={{ width: `${s.score}%` }} />
</div>
</div>
))}
</div>
{pt.recommendedClassId ? (
<div className="mt-4 rounded-[12px] bg-brand-50 px-3 py-2.5 text-[13px] text-brand-700">
Rekomendasi kelas:{" "}
<b>{classes.find((c) => c.id === pt.recommendedClassId)?.name ?? "-"}</b>
</div>
) : null}
{pt.notes ? <p className="mt-3 text-[12.5px] text-ink-500">{pt.notes}</p> : null}
</CardBody>
</Card>
))
)}
</div>
) : null}
{/* ── TAB: RAPORT ────────────────────────────────── */}
{tab === "raport" ? (
<div className="space-y-4">
{student.reportCards.length === 0 ? (
<EmptyState icon={<FileText className="size-6" />} title="Belum ada raport" />
) : (
student.reportCards.map((rc) => (
<Card key={rc.id}>
<CardHeader>
<div>
<CardTitle>{rc.class.name}</CardTitle>
<p className="text-[12px] text-ink-500">
{rc.period} · {rc.teacher?.name ?? "-"}
</p>
</div>
<div className="text-right">
<p className="font-num text-[26px] font-bold text-ink-900">{rc.finalScore}</p>
<Badge bg="#E4F8F1" fg="#0A7A58">
Grade {rc.grade ?? gradeFromScore(rc.finalScore ?? 0)}
</Badge>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{rc.scores.map((s) => (
<div key={s.id} className="rounded-[12px] bg-canvas px-3 py-2.5">
<p className="text-[12px] text-ink-500">{s.aspect}</p>
<p className="font-num text-[16px] font-bold text-ink-900">{s.score}</p>
</div>
))}
</div>
{rc.teacherNotes ? (
<p className="mt-3 rounded-[12px] bg-canvas px-3 py-2.5 text-[13px] text-ink-700">
{rc.teacherNotes}
</p>
) : null}
</CardBody>
</Card>
))
)}
</div>
) : null}
{/* ── TAB: JURNAL ────────────────────────────────── */}
{tab === "jurnal" ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{student.journalStudents.length === 0 ? (
<div className="sm:col-span-2">
<EmptyState icon={<NotebookPen className="size-6" />} title="Belum ada jurnal untuk siswa ini" />
</div>
) : (
student.journalStudents.map((js) => (
<Card key={js.journalId} className="overflow-hidden">
{js.journal.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={js.journal.photos[0].url} alt={js.journal.title} className="h-40 w-full object-cover" />
) : (
<div className="grad-pink flex h-40 items-center justify-center text-white">
<NotebookPen className="size-8 opacity-80" />
</div>
)}
<CardBody className="pt-3">
<p className="text-[11px] font-medium text-ink-500">{js.journal.class.name}</p>
<h4 className="mt-0.5 text-[15px] font-semibold text-ink-900">{js.journal.title}</h4>
<p className="mt-1 line-clamp-2 text-[13px] text-ink-500">{js.journal.description}</p>
<p className="mt-2 text-[12px] text-ink-500">{formatRelativeID(js.journal.activityDate)}</p>
</CardBody>
</Card>
))
)}
</div>
) : null}
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 border-b border-ink-100 pb-2.5 last:border-0">
<span className="shrink-0 text-ink-500">{label}</span>
<span className="text-right font-medium text-ink-900">{value}</span>
</div>
);
}
function AttendancePill({ status }: { status: string }) {
const meta: Record<string, { label: string; bg: string; text: string }> = {
PRESENT: { label: "Hadir", bg: "#E4F8F1", text: "#0A7A58" },
LATE: { label: "Terlambat", bg: "#FFF4E0", text: "#9A6100" },
EXCUSED: { label: "Izin", bg: "#E3F3FF", text: "#0B6FB8" },
SICK: { label: "Sakit", bg: "#F1ECFE", text: "#5B34C9" },
ABSENT: { label: "Alpa", bg: "#FEE4E2", text: "#B42318" },
};
const m = meta[status] ?? { label: status, bg: "#EEF1F7", text: "#4A5470" };
return (
<Badge bg={m.bg} fg={m.text}>
{m.label}
</Badge>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { studentSchema } from "@/lib/validators";
export type ActionState = { error?: string; ok?: boolean } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
async function syncEnrollments(studentId: string, classIds: string[]) {
const existing = await prisma.enrollment.findMany({ where: { studentId } });
const existingIds = existing.map((e) => e.classId);
for (const classId of classIds) {
if (existingIds.includes(classId)) continue;
const cls = await prisma.class.findUnique({
where: { id: classId },
include: { _count: { select: { enrollments: { where: { status: "ACTIVE" } } } } },
});
if (!cls) continue;
if (cls._count.enrollments >= cls.capacity) {
throw new Error(`Kelas ${cls.name} sudah penuh (${cls.capacity} siswa).`);
}
await prisma.enrollment.create({ data: { studentId, classId } });
}
const removeIds = existing.filter((e) => !classIds.includes(e.classId)).map((e) => e.id);
if (removeIds.length) await prisma.enrollment.deleteMany({ where: { id: { in: removeIds } } });
}
export async function createStudentAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
await requireUser();
const payload = {
...Object.fromEntries(formData),
classIds: formData.getAll("classIds").map(String),
};
const parsed = studentSchema.safeParse(payload);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
try {
const created = await prisma.student.create({
data: {
fullName: d.fullName,
phone: d.phone,
email: nullable(d.email),
photoUrl: nullable(d.photoUrl),
parentName: nullable(d.parentName),
parentPhone: nullable(d.parentPhone),
gender: nullable(d.gender),
birthDate: parseDate(d.birthDate),
address: nullable(d.address),
leadSource: d.leadSource,
status: d.status,
notes: nullable(d.notes),
joinedAt: d.status === "STUDENT" ? new Date() : null,
},
});
if (d.status === "STUDENT") await syncEnrollments(created.id, d.classIds);
revalidatePath("/students");
revalidatePath("/dashboard");
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal menyimpan siswa." };
}
redirect("/students");
}
export async function updateStudentAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
await requireUser();
const id = String(formData.get("id"));
const payload = {
...Object.fromEntries(formData),
classIds: formData.getAll("classIds").map(String),
};
const parsed = studentSchema.safeParse(payload);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
try {
const before = await prisma.student.findUnique({ where: { id } });
await prisma.student.update({
where: { id },
data: {
fullName: d.fullName,
phone: d.phone,
email: nullable(d.email),
photoUrl: nullable(d.photoUrl),
parentName: nullable(d.parentName),
parentPhone: nullable(d.parentPhone),
gender: nullable(d.gender),
birthDate: parseDate(d.birthDate),
address: nullable(d.address),
leadSource: d.leadSource,
status: d.status,
notes: nullable(d.notes),
joinedAt: d.status === "STUDENT" && !before?.joinedAt ? new Date() : before?.joinedAt,
archivedAt: d.status === "EX_STUDENT" ? new Date() : null,
},
});
if (d.status === "STUDENT" || d.status === "EX_STUDENT") await syncEnrollments(id, d.classIds);
revalidatePath("/students");
revalidatePath(`/students/${id}`);
revalidatePath("/dashboard");
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal memperbarui siswa." };
}
redirect(`/students/${id}`);
}
export async function archiveStudentAction(formData: FormData) {
await requireUser();
const id = String(formData.get("id"));
await prisma.student.update({ where: { id }, data: { archivedAt: new Date() } });
revalidatePath("/students");
redirect("/students");
}
+18
View File
@@ -0,0 +1,18 @@
import { requireUser } from "@/lib/rbac";
import { getClassOptions } from "@/lib/queries";
import { PageHeader } from "@/components/ui/page-header";
import { StudentForm } from "@/components/students/student-form";
import { createStudentAction } from "../actions";
export const metadata = { title: "Tambah Siswa — Kodeva" };
export default async function NewStudentPage() {
await requireUser();
const classes = await getClassOptions();
return (
<div>
<PageHeader title="Tambah Siswa Baru" description="Lengkapi data siswa dan sumber lead untuk follow up." />
<StudentForm action={createStudentAction} classes={classes} mode="create" />
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
import Link from "next/link";
import { BellRing, Filter, Plus, Search, Users } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID } from "@/lib/utils";
import { LEAD_SOURCES, LEAD_SOURCE_META, STUDENT_STATUSES, STUDENT_STATUS_META } from "@/lib/constants";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { SourceChip, StatusBadge } from "@/components/students/badges";
import { cn } from "@/lib/utils";
export const metadata = { title: "Siswa — Kodeva" };
type SP = Promise<{ status?: string; q?: string; source?: string; classId?: string }>;
export default async function StudentsPage({ searchParams }: { searchParams: SP }) {
await requireUser();
const sp = await searchParams;
const status = sp.status && STUDENT_STATUSES.includes(sp.status as never) ? sp.status : undefined;
const [students, classes] = await Promise.all([
prisma.student.findMany({
where: {
archivedAt: null,
...(status ? { status } : {}),
...(sp.source ? { leadSource: sp.source } : {}),
...(sp.classId ? { enrollments: { some: { classId: sp.classId } } } : {}),
...(sp.q
? {
OR: [
{ fullName: { contains: sp.q } },
{ email: { contains: sp.q } },
{ phone: { contains: sp.q } },
],
}
: {}),
},
include: { enrollments: { include: { class: true } } },
orderBy: { createdAt: "desc" },
}),
prisma.class.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
]);
return (
<div>
<PageHeader
title="Siswa"
description="Kelola calon siswa, siswa aktif, dan ex student."
actions={
<Link href="/students/new">
<Button>
<Plus className="size-4" /> Tambah Siswa
</Button>
</Link>
}
/>
{/* Filter status */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Link
href="/students"
className={cn(
"rounded-full px-3.5 py-1.5 text-[13px] font-medium transition-colors",
!status ? "grad-primary text-white" : "border border-ink-100 bg-surface text-ink-700 hover:bg-ink-100",
)}
>
Semua
</Link>
{STUDENT_STATUSES.map((s) => (
<Link
key={s}
href={`/students?status=${s}`}
className={cn(
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[13px] font-medium transition-colors",
status === s ? "text-white" : "border border-ink-100 bg-surface text-ink-700 hover:bg-ink-100",
)}
style={status === s ? { backgroundColor: STUDENT_STATUS_META[s].color } : undefined}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: status === s ? "#fff" : STUDENT_STATUS_META[s].color }} />
{STUDENT_STATUS_META[s].label}
</Link>
))}
</div>
<Card className="mb-4 p-3">
<form className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-500" />
<input
name="q"
defaultValue={sp.q}
placeholder="Cari nama, email, atau telepon..."
className="h-10 w-full rounded-[12px] border border-ink-300 bg-surface pl-9 pr-3 text-sm placeholder:text-ink-500 focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-500/25"
/>
</div>
<select
name="source"
defaultValue={sp.source ?? ""}
className="h-10 rounded-[12px] border border-ink-300 bg-surface px-3 text-sm text-ink-700 focus:outline-none"
>
<option value="">Semua sumber</option>
{LEAD_SOURCES.map((s) => (
<option key={s} value={s}>
{LEAD_SOURCE_META[s].label}
</option>
))}
</select>
<select
name="classId"
defaultValue={sp.classId ?? ""}
className="h-10 rounded-[12px] border border-ink-300 bg-surface px-3 text-sm text-ink-700 focus:outline-none"
>
<option value="">Semua kelas</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{status ? <input type="hidden" name="status" value={status} /> : null}
<Button variant="outline" type="submit">
<Filter className="size-4" /> Filter
</Button>
</form>
</Card>
{students.length === 0 ? (
<EmptyState
icon={<Users className="size-6" />}
title="Belum ada siswa di sini"
description="Tambahkan calon siswa baru atau ubah filter pencarian."
action={
<Link href="/students/new">
<Button>
<Plus className="size-4" /> Tambah Siswa
</Button>
</Link>
}
/>
) : (
<>
{/* Tabel (desktop) */}
<Card className="hidden overflow-hidden lg:block">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-100 text-[12px] uppercase tracking-wide text-ink-500">
<th className="px-5 py-3 font-semibold">Siswa</th>
<th className="px-3 py-3 font-semibold">Orang Tua</th>
<th className="px-3 py-3 font-semibold">Kelas</th>
<th className="px-3 py-3 font-semibold">Sumber</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-3 py-3 font-semibold">Terdaftar</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{students.map((s) => (
<tr key={s.id} className="transition-colors hover:bg-brand-50/40">
<td className="px-5 py-3">
<Link href={`/students/${s.id}`} className="flex items-center gap-3">
<Avatar src={s.photoUrl} name={s.fullName} size={38} />
<div>
<p className="font-semibold text-ink-900">{s.fullName}</p>
<p className="text-[12px] text-ink-500">{s.phone}</p>
</div>
</Link>
</td>
<td className="px-3 py-3">
<p className="text-ink-900">{s.parentName ?? "-"}</p>
<p className="text-[12px] text-ink-500">{s.parentPhone ?? ""}</p>
</td>
<td className="px-3 py-3">
<div className="flex flex-wrap gap-1">
{s.enrollments.length === 0 ? (
<span className="text-[12px] text-ink-500">-</span>
) : (
s.enrollments.map((e) => (
<span key={e.id} className="rounded-full bg-ink-100 px-2 py-0.5 text-[11px] font-medium text-ink-700">
{e.class.name}
</span>
))
)}
</div>
</td>
<td className="px-3 py-3">
<SourceChip source={s.leadSource} />
</td>
<td className="px-3 py-3">
<StatusBadge status={s.status} />
</td>
<td className="px-3 py-3 text-[12.5px] text-ink-500">{formatDateID(s.joinedAt ?? s.createdAt)}</td>
<td className="px-5 py-3 text-right">
<Link href={`/students/${s.id}`} className="text-[13px] font-semibold text-brand-600 hover:underline">
Detail
</Link>
</td>
</tr>
))}
</tbody>
</table>
</Card>
{/* Kartu (mobile) */}
<div className="space-y-3 lg:hidden">
{students.map((s) => (
<Link key={s.id} href={`/students/${s.id}`}>
<Card className="flex items-center gap-3 p-4">
<Avatar src={s.photoUrl} name={s.fullName} size={44} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-ink-900">{s.fullName}</p>
<p className="truncate text-[12px] text-ink-500">{s.phone}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<StatusBadge status={s.status} />
<SourceChip source={s.leadSource} />
</div>
</div>
</Card>
</Link>
))}
</div>
</>
)}
<p className="mt-4 flex items-center gap-1.5 text-[12px] text-ink-500">
<BellRing className="size-3.5" /> Menampilkan {students.length} siswa
</p>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse, type NextRequest } from "next/server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { getSession } from "@/lib/auth";
export const runtime = "nodejs";
const ALLOWED = ["image/jpeg", "image/png", "image/webp", "image/gif"];
export async function POST(req: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Silakan login." } }, { status: 401 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: { code: "NO_FILE", message: "File tidak ditemukan." } }, { status: 400 });
}
if (!ALLOWED.includes(file.type)) {
return NextResponse.json({ error: { code: "BAD_TYPE", message: "Format harus JPG, PNG, WebP, atau GIF." } }, { status: 400 });
}
const maxMb = Number(process.env.MAX_UPLOAD_MB ?? 5);
if (file.size > maxMb * 1024 * 1024) {
return NextResponse.json({ error: { code: "TOO_LARGE", message: `Ukuran maksimal ${maxMb}MB.` } }, { status: 400 });
}
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
const filename = `${randomUUID()}.${ext}`;
const dir = path.join(process.cwd(), "public", "uploads");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, filename), Buffer.from(await file.arrayBuffer()));
return NextResponse.json({ data: { url: `/uploads/${filename}` } });
}
+171
View File
@@ -0,0 +1,171 @@
@import "tailwindcss";
@theme {
/* ── Brand ─────────────────────────────────────────── */
--color-brand-50: #f2effe;
--color-brand-100: #e4defd;
--color-brand-200: #cec2fc;
--color-brand-300: #b9a9fb;
--color-brand-400: #9b82fb;
--color-brand-500: #7c5cfc;
--color-brand-600: #6a46ef;
--color-brand-700: #5533ce;
/* ── Accent & semantic ─────────────────────────────── */
--color-coral-500: #ff7a59;
--color-coral-100: #ffe7e0;
--color-amber-500: #ffb020;
--color-amber-100: #fff4e0;
--color-mint-500: #12b886;
--color-mint-100: #e4f8f1;
--color-sky-500: #2aa9ff;
--color-sky-100: #e3f3ff;
--color-pink-500: #f65fa7;
--color-pink-100: #fde7f2;
--color-danger-500: #f04438;
--color-danger-100: #fee4e2;
--color-violet-soft: #a78bfa;
/* ── Neutral ───────────────────────────────────────── */
--color-ink-900: #131a2b;
--color-ink-700: #3a4560;
--color-ink-500: #6b7590;
--color-ink-300: #c4cad8;
--color-ink-100: #eef1f7;
--color-canvas: #f7f8fc;
--color-surface: #ffffff;
/* ── Fonts ─────────────────────────────────────────── */
--font-display: var(--font-jakarta), system-ui, sans-serif;
--font-body: var(--font-inter), system-ui, sans-serif;
--font-num: var(--font-sora), system-ui, sans-serif;
--font-mono: var(--font-mono-jb), ui-monospace, monospace;
/* ── Radius ────────────────────────────────────────── */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-2xl: 24px;
/* ── Shadows ───────────────────────────────────────── */
--shadow-xs: 0 1px 2px rgba(19, 26, 43, 0.06);
--shadow-sm: 0 2px 8px rgba(19, 26, 43, 0.06);
--shadow-md: 0 8px 24px rgba(19, 26, 43, 0.08);
--shadow-lg: 0 16px 40px rgba(19, 26, 43, 0.1);
--shadow-brand: 0 8px 20px rgba(124, 92, 252, 0.28);
}
:root {
--grad-primary: linear-gradient(135deg, #7c5cfc 0%, #2aa9ff 100%);
--grad-coral: linear-gradient(135deg, #ff7a59 0%, #ffb020 100%);
--grad-mint: linear-gradient(135deg, #12b886 0%, #2aa9ff 100%);
--grad-pink: linear-gradient(135deg, #f65fa7 0%, #7c5cfc 100%);
}
* {
border-color: var(--color-ink-100);
}
html,
body {
height: 100%;
}
body {
background: var(--color-canvas);
color: var(--color-ink-900);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
h5 {
font-family: var(--font-display);
letter-spacing: -0.02em;
color: var(--color-ink-900);
}
.font-num {
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}
.grad-primary {
background-image: var(--grad-primary);
}
.grad-coral {
background-image: var(--grad-coral);
}
.grad-mint {
background-image: var(--grad-mint);
}
.grad-pink {
background-image: var(--grad-pink);
}
/* Scrollbar halus */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--color-ink-300);
border-radius: 9999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* Fokus konsisten */
:where(button, a, input, select, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.28);
border-radius: var(--radius-sm);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fade-up 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
.skeleton {
position: relative;
overflow: hidden;
background: var(--color-ink-100);
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
animation: shimmer 1.4s infinite;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Plus_Jakarta_Sans, Sora } from "next/font/google";
import "./globals.css";
const jakarta = Plus_Jakarta_Sans({ subsets: ["latin"], variable: "--font-jakarta", display: "swap" });
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
const sora = Sora({ subsets: ["latin"], variable: "--font-sora", display: "swap" });
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono-jb", display: "swap" });
export const metadata: Metadata = {
title: "Kodeva — Sistem Manajemen Kursus Coding",
description: "Kelola kelas coding, follow up siswa, pembayaran prorata, jurnal, dan raport dalam satu tempat.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="id" className={`${jakarta.variable} ${inter.variable} ${sora.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { loginSchema } from "@/lib/validators";
import { clearSessionCookie, setSessionCookie } from "@/lib/auth";
export type LoginState = { error?: string } | undefined;
export async function loginAction(_prev: LoginState, formData: FormData): Promise<LoginState> {
const parsed = loginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) return { error: "Email atau password tidak valid." };
const user = await prisma.user.findUnique({ where: { email: parsed.data.email } });
if (!user || !user.isActive || !bcrypt.compareSync(parsed.data.password, user.passwordHash)) {
return { error: "Email atau password salah." };
}
await setSessionCookie({
userId: user.id,
name: user.name,
email: user.email,
role: user.role === "ADMIN" ? "ADMIN" : "TEACHER",
});
redirect("/dashboard");
}
export async function logoutAction() {
await clearSessionCookie();
redirect("/login");
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useActionState } from "react";
import { AlertCircle, LogIn } from "lucide-react";
import { loginAction, type LoginState } from "./actions";
import { Field, Input } from "@/components/ui/form";
import { SubmitButton } from "@/components/ui/submit-button";
export function LoginForm() {
const [state, action] = useActionState<LoginState, FormData>(loginAction, undefined);
return (
<form action={action} className="space-y-4">
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<Field label="Email" required>
<Input name="email" type="email" placeholder="admin@kodeva.id" defaultValue="admin@kodeva.id" autoComplete="email" />
</Field>
<Field label="Password" required>
<Input name="password" type="password" placeholder="••••••••" defaultValue="password123" autoComplete="current-password" />
</Field>
<SubmitButton className="w-full" size="lg" pendingText="Masuk...">
<LogIn className="size-4" />
Masuk
</SubmitButton>
<p className="rounded-[12px] bg-canvas px-3 py-2 text-center text-[12px] text-ink-500">
Demo: <span className="font-mono">admin@kodeva.id</span> / <span className="font-mono">password123</span>
</p>
</form>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { redirect } from "next/navigation";
import { Code2 } from "lucide-react";
import { getSession } from "@/lib/auth";
import { LoginForm } from "./login-form";
export const metadata = { title: "Masuk — Kodeva" };
export default async function LoginPage() {
const session = await getSession();
if (session) redirect("/dashboard");
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-canvas px-4">
<div className="pointer-events-none absolute -left-24 -top-24 size-80 rounded-full bg-brand-300/30 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -right-24 size-80 rounded-full bg-sky-500/20 blur-3xl" />
<div className="relative w-full max-w-[900px] overflow-hidden rounded-[28px] border border-ink-100 bg-surface shadow-[var(--shadow-lg)] md:grid md:grid-cols-2">
<div className="grad-primary relative hidden flex-col justify-between p-8 text-white md:flex">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-[14px] bg-white/20 backdrop-blur">
<Code2 className="size-6" />
</span>
<span className="text-[22px] font-extrabold tracking-tight">Kodeva</span>
</div>
<div>
<h2 className="text-[30px] font-extrabold leading-tight">
Kelola kelas coding,
<br />
tumbuhkan pembuat
<br />
masa depan.
</h2>
<p className="mt-3 max-w-xs text-[13px] text-white/80">
Follow up siswa, kehadiran, pembayaran prorata, jurnal, dan raport semua dalam satu dashboard.
</p>
</div>
<div className="flex gap-2">
<span className="rounded-full bg-white/20 px-3 py-1 text-[12px] font-medium">Follow Up</span>
<span className="rounded-full bg-white/20 px-3 py-1 text-[12px] font-medium">Prorata</span>
<span className="rounded-full bg-white/20 px-3 py-1 text-[12px] font-medium">Raport</span>
</div>
</div>
<div className="p-8 sm:p-10">
<div className="mb-6 md:hidden">
<span className="grad-primary mb-4 flex size-11 items-center justify-center rounded-[14px] text-white">
<Code2 className="size-6" />
</span>
</div>
<h1 className="text-[24px] font-extrabold text-ink-900">Selamat datang kembali 👋</h1>
<p className="mb-6 mt-1 text-[13px] text-ink-500">Masuk untuk melanjutkan mengelola kursus.</p>
<LoginForm />
</div>
</div>
</main>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export default async function RootPage() {
const session = await getSession();
redirect(session ? "/dashboard" : "/login");
}
@@ -0,0 +1,99 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CheckCheck, Loader2, Save } from "lucide-react";
import { saveAttendanceAction } from "@/app/(app)/attendance/actions";
import { ATTENDANCE_STATUSES, ATTENDANCE_META } from "@/lib/constants";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function AttendanceSheet({
sessionId,
students,
existing,
}: {
sessionId: string;
students: { id: string; fullName: string; photoUrl: string | null }[];
existing: Record<string, string>;
}) {
const [statuses, setStatuses] = useState<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const s of students) m[s.id] = existing[s.id] ?? "PRESENT";
return m;
});
const [pending, startTransition] = useTransition();
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const router = useRouter();
const done = Object.values(statuses).length;
function save() {
setMsg(null);
startTransition(async () => {
const entries = students.map((s) => ({ studentId: s.id, status: statuses[s.id] ?? "PRESENT", note: "" }));
const res = await saveAttendanceAction({ sessionId, entries });
if (res?.error) setMsg({ type: "err", text: res.error });
else {
setMsg({ type: "ok", text: "Kehadiran tersimpan ✓" });
router.refresh();
}
});
}
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-[13px] text-ink-500">
{done}/{students.length} siswa terisi
</p>
<div className="flex items-center gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => setStatuses(() => Object.fromEntries(students.map((s) => [s.id, "PRESENT"])))}>
<CheckCheck className="size-4" /> Hadir Semua
</Button>
<Button size="sm" onClick={save} disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
Simpan
</Button>
</div>
</div>
{msg ? (
<div className={cn("mb-3 rounded-[12px] px-3 py-2 text-[13px] font-medium", msg.type === "ok" ? "bg-mint-100 text-mint-500" : "bg-danger-100 text-danger-500")}>
{msg.text}
</div>
) : null}
<div className="space-y-2">
{students.map((s) => (
<div key={s.id} className="flex flex-wrap items-center gap-3 rounded-[14px] border border-ink-100 px-3 py-2.5">
<Avatar src={s.photoUrl} name={s.fullName} size={34} />
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink-900">{s.fullName}</span>
<div className="flex gap-1.5">
{ATTENDANCE_STATUSES.map((st) => {
const meta = ATTENDANCE_META[st];
const active = (statuses[s.id] ?? "PRESENT") === st;
return (
<button
key={st}
type="button"
title={meta.label}
onClick={() => setStatuses((p) => ({ ...p, [s.id]: st }))}
className={cn(
"flex size-9 items-center justify-center rounded-[10px] border text-[12px] font-bold transition-all",
active ? "text-white shadow-sm" : "border-ink-100 bg-surface text-ink-500 hover:bg-ink-100",
)}
style={active ? { backgroundColor: meta.color } : undefined}
>
{meta.short}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis } from "recharts";
export function ActiveStudentsChart({ data }: { data: { month: string; value: number }[] }) {
return (
<div className="h-[180px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
<defs>
<linearGradient id="gradStudents" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7C5CFC" stopOpacity={0.35} />
<stop offset="100%" stopColor="#7C5CFC" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#EEF1F7" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 11, fill: "#6B7590" }} />
<Tooltip
contentStyle={{ borderRadius: 12, border: "1px solid #EEF1F7", boxShadow: "0 8px 24px rgba(19,26,43,.08)", fontSize: 12 }}
labelStyle={{ fontWeight: 600 }}
formatter={(v) => [`${v} siswa`, "Aktif"] as [string, string]}
/>
<Area type="monotone" dataKey="value" stroke="#7C5CFC" strokeWidth={2.5} fill="url(#gradStudents)" />
</AreaChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
export function AttendanceDonut({
present,
late,
absent,
rate,
}: {
present: number;
late: number;
absent: number;
rate: number;
}) {
const data = [
{ name: "Hadir", value: present, color: "#12B886" },
{ name: "Terlambat", value: late, color: "#FFB020" },
{ name: "Alpa/Izin", value: absent, color: "#F04438" },
].filter((d) => d.value > 0);
return (
<div className="relative h-[168px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" innerRadius={52} outerRadius={72} paddingAngle={3} stroke="none">
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<Tooltip
contentStyle={{ borderRadius: 12, border: "1px solid #EEF1F7", fontSize: 12 }}
formatter={(v, n) => [`${v} siswa`, String(n)] as [string, string]}
/>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="font-num text-[26px] font-bold text-ink-900">{rate}%</span>
<span className="text-[11px] text-ink-500">Kehadiran</span>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export function LeadSourceBar({ data }: { data: { label: string; value: number; color: string }[] }) {
return (
<div className="h-[190px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 28, bottom: 4, left: 8 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="label"
width={92}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11.5, fill: "#3A4560" }}
/>
<Tooltip
cursor={{ fill: "#F2EFFE" }}
contentStyle={{ borderRadius: 12, border: "1px solid #EEF1F7", fontSize: 12 }}
formatter={(v) => [`${v} siswa`, "Jumlah"] as [string, string]}
/>
<Bar dataKey="value" radius={[6, 6, 6, 6]} barSize={16}>
{data.map((d) => (
<Cell key={d.label} fill={d.color} />
))}
<LabelList dataKey="value" position="right" style={{ fontSize: 11.5, fill: "#3A4560", fontWeight: 600 }} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { AlertCircle, Save, X } from "lucide-react";
import type { ClassState } from "@/app/(app)/classes/actions";
import { Field, Input, Select, Textarea } from "@/components/ui/form";
import { RadioPills } from "@/components/ui/radio-pills";
import { SubmitButton } from "@/components/ui/submit-button";
import { Button } from "@/components/ui/button";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { CLASS_STATUSES, LEVELS, LEVEL_META } from "@/lib/constants";
const PRESET_COLORS = ["#7C5CFC", "#2AA9FF", "#12B886", "#FFB020", "#F65FA7", "#FF7A59", "#A78BFA"];
export type ClassInitial = {
id?: string;
name?: string;
code?: string;
description?: string | null;
level?: string;
teacherId?: string | null;
totalMeetings?: number;
packagePrice?: number;
pricePerMeeting?: number | null;
capacity?: number;
schedule?: string | null;
color?: string | null;
status?: string;
startDate?: string | null;
};
export function ClassForm({
action,
teachers,
initial,
mode,
}: {
action: (prev: ClassState, formData: FormData) => Promise<ClassState>;
teachers: { id: string; name: string }[];
initial?: ClassInitial;
mode: "create" | "edit";
}) {
const [state, formAction] = useActionState<ClassState, FormData>(action, undefined);
const defaultColor = initial?.color ?? "#7C5CFC";
return (
<form action={formAction} className="space-y-6">
{initial?.id ? <input type="hidden" name="id" value={initial.id} /> : null}
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Informasi Kelas</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Nama kelas" required>
<Input name="name" defaultValue={initial?.name} placeholder="Mis. Python Dasar" />
</Field>
<Field label="Kode kelas" required hint="Unik, mis. KOD-PYT-01">
<Input name="code" defaultValue={initial?.code} placeholder="KOD-PYT-01" />
</Field>
</div>
<Field label="Deskripsi">
<Textarea name="description" defaultValue={initial?.description ?? ""} placeholder="Apa yang dipelajari di kelas ini?" />
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Level" required>
<Select name="level" defaultValue={initial?.level ?? "BEGINNER"}>
{LEVELS.map((l) => (
<option key={l} value={l}>
{LEVEL_META[l].label}
</option>
))}
</Select>
</Field>
<Field label="Guru / Pengajar">
<Select name="teacherId" defaultValue={initial?.teacherId ?? ""}>
<option value="">Pilih guru...</option>
{teachers.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
</Field>
</div>
<Field label="Jadwal">
<Input name="schedule" defaultValue={initial?.schedule ?? ""} placeholder="Senin & Rabu 16:00-17:30" />
</Field>
<Field label="Tanggal mulai">
<Input name="startDate" type="date" defaultValue={initial?.startDate ?? ""} />
</Field>
</CardBody>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Paket & Kapasitas</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field label="Jumlah pertemuan" required>
<Input name="totalMeetings" type="number" min={1} defaultValue={initial?.totalMeetings ?? 10} />
</Field>
<Field label="Harga paket (Rp)" required hint="Harga untuk seluruh pertemuan">
<Input name="packagePrice" type="number" min={0} defaultValue={initial?.packagePrice ?? 0} />
</Field>
</div>
<Field label="Harga per pertemuan (opsional)" hint="Kosongkan untuk otomatis = harga paket ÷ pertemuan">
<Input name="pricePerMeeting" type="number" min={0} defaultValue={initial?.pricePerMeeting ?? ""} placeholder="Otomatis" />
</Field>
<Field label="Kapasitas siswa" required>
<Input name="capacity" type="number" min={1} max={100} defaultValue={initial?.capacity ?? 12} />
</Field>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Warna & Status</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<Field label="Warna kelas">
<div className="flex flex-wrap items-center gap-2">
{PRESET_COLORS.map((c) => (
<label key={c} className="cursor-pointer">
<input type="radio" name="color" value={c} defaultChecked={c === defaultColor} className="peer sr-only" />
<span
className="flex size-8 items-center justify-center rounded-full border-2 border-transparent transition-transform hover:scale-110 peer-checked:border-ink-900"
style={{ backgroundColor: c }}
/>
</label>
))}
</div>
</Field>
<Field label="Status" required>
<RadioPills
name="status"
defaultValue={initial?.status ?? "ACTIVE"}
options={CLASS_STATUSES.map((s) => ({ value: s, label: s.charAt(0) + s.slice(1).toLowerCase() }))}
/>
</Field>
</CardBody>
</Card>
</div>
</div>
<div className="sticky bottom-4 flex items-center justify-end gap-2 rounded-[16px] border border-ink-100 bg-surface/90 px-4 py-3 shadow-[var(--shadow-md)] backdrop-blur">
<Link href="/classes">
<Button type="button" variant="outline">
<X className="size-4" /> Batal
</Button>
</Link>
<SubmitButton pendingText="Menyimpan...">
<Save className="size-4" /> {mode === "create" ? "Simpan Kelas" : "Simpan Perubahan"}
</SubmitButton>
</div>
</form>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { updateFollowUpAction, type FollowUpState } from "@/app/(app)/follow-up/actions";
import { FOLLOW_UP_STATUSES, FOLLOW_UP_META } from "@/lib/constants";
export function FollowUpStatusForm({ id, status }: { id: string; status: string }) {
const [state, action] = useActionState<FollowUpState, FormData>(updateFollowUpAction, undefined);
const router = useRouter();
useEffect(() => {
if (state?.ok) router.refresh();
}, [state, router]);
return (
<form action={action}>
<input type="hidden" name="id" value={id} />
<select
name="status"
defaultValue={status}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
className="h-8 cursor-pointer rounded-[10px] border border-ink-100 bg-canvas px-2 text-[12px] font-medium text-ink-700 focus:outline-none"
>
{FOLLOW_UP_STATUSES.map((s) => (
<option key={s} value={s}>
{FOLLOW_UP_META[s].label}
</option>
))}
</select>
{state?.error ? <p className="mt-1 text-[11px] text-danger-500">{state.error}</p> : null}
</form>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useActionState, useState } from "react";
import Link from "next/link";
import { AlertCircle, Save, X } from "lucide-react";
import type { JournalState } from "@/app/(app)/journals/actions";
import { Field, Input, Select, Textarea } from "@/components/ui/form";
import { SubmitButton } from "@/components/ui/submit-button";
import { Button } from "@/components/ui/button";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { MultiUpload } from "@/components/ui/multi-upload";
type ClassOpt = { id: string; name: string; color: string | null; sessions: { id: string; meetingNumber: number; topic: string | null }[] };
type StudentOpt = { id: string; fullName: string; enrolledClassIds: string[] };
export function JournalForm({
action,
classes,
students,
}: {
action: (prev: JournalState, formData: FormData) => Promise<JournalState>;
classes: ClassOpt[];
students: StudentOpt[];
}) {
const [state, formAction] = useActionState<JournalState, FormData>(action, undefined);
const [classId, setClassId] = useState(classes[0]?.id ?? "");
const cls = classes.find((c) => c.id === classId);
const roster = students.filter((s) => s.enrolledClassIds.includes(classId));
return (
<form action={formAction} className="space-y-6">
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detail Aktivitas</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<Field label="Judul aktivitas" required>
<Input name="title" placeholder="Mis. Membuat Game Ular dengan Scratch" />
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Kelas" required>
<Select name="classId" value={classId} onChange={(e) => setClassId(e.target.value)}>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Field>
<Field label="Pertemuan terkait">
<Select name="sessionId" defaultValue="">
<option value="">- Pilih pertemuan -</option>
{cls?.sessions.map((s) => (
<option key={s.id} value={s.id}>
#{s.meetingNumber} {s.topic ?? ""}
</option>
))}
</Select>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Tanggal aktivitas">
<Input name="activityDate" type="date" />
</Field>
<Field label="Tag (pisahkan koma)">
<Input name="tags" placeholder="game, scratch, logika" />
</Field>
</div>
<Field label="Deskripsi">
<Textarea name="description" placeholder="Ceritakan aktivitas di kelas..." />
</Field>
</CardBody>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Dokumentasi Foto</CardTitle>
<span className="text-[12px] text-ink-500">Bisa lebih dari satu</span>
</CardHeader>
<CardBody>
<MultiUpload name="photoUrls" />
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Siswa Terlibat</CardTitle>
<span className="text-[12px] text-ink-500">{roster.length} siswa di kelas ini</span>
</CardHeader>
<CardBody>
{roster.length === 0 ? (
<p className="text-[13px] text-ink-500">Pilih kelas terlebih dahulu untuk melihat siswanya.</p>
) : (
<div className="grid grid-cols-2 gap-2">
{roster.map((s) => (
<label key={s.id} className="flex cursor-pointer items-center gap-2 rounded-[12px] border border-ink-100 px-3 py-2 hover:bg-canvas">
<input type="checkbox" name="studentIds" value={s.id} className="size-4 accent-[var(--color-brand-500)]" />
<span className="truncate text-[13px] font-medium text-ink-900">{s.fullName}</span>
</label>
))}
</div>
)}
</CardBody>
</Card>
</div>
</div>
<div className="sticky bottom-4 flex items-center justify-end gap-2 rounded-[16px] border border-ink-100 bg-surface/90 px-4 py-3 shadow-[var(--shadow-md)] backdrop-blur">
<Link href="/journals">
<Button type="button" variant="outline">
<X className="size-4" /> Batal
</Button>
</Link>
<SubmitButton pendingText="Menyimpan...">
<Save className="size-4" /> Simpan Jurnal
</SubmitButton>
</div>
</form>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
BellRing,
CalendarCheck,
Code2,
FileText,
GraduationCap,
LayoutDashboard,
Menu,
NotebookPen,
Settings,
Target,
Users,
Wallet,
X,
} from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
const NAV = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/follow-up", label: "Follow Up", icon: BellRing, badge: true },
{ href: "/students", label: "Siswa", icon: Users },
{ href: "/classes", label: "Kelas", icon: GraduationCap },
{ href: "/attendance", label: "Kehadiran", icon: CalendarCheck },
{ href: "/payments", label: "Pembayaran", icon: Wallet },
{ href: "/journals", label: "Jurnal Aktivitas", icon: NotebookPen },
{ href: "/report-cards", label: "Raport", icon: FileText },
{ href: "/placements", label: "Placement Test", icon: Target },
{ href: "/settings", label: "Pengaturan", icon: Settings },
];
export function Sidebar({
followUpCount,
user,
}: {
followUpCount: number;
user: { name: string; email: string; role: string; avatarUrl?: string | null };
}) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const nav = (
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-2">
{NAV.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className={cn(
"group flex items-center gap-3 rounded-[12px] px-3 py-2.5 text-[13.5px] font-medium transition-all",
active
? "grad-primary text-white shadow-[var(--shadow-brand)]"
: "text-ink-700 hover:bg-brand-50 hover:text-brand-700",
)}
>
<Icon className="size-[18px] shrink-0" />
<span className="flex-1">{item.label}</span>
{item.badge && followUpCount > 0 ? (
<span
className={cn(
"min-w-5 rounded-full px-1.5 text-center text-[11px] font-bold",
active ? "bg-white/25 text-white" : "bg-danger-500 text-white",
)}
>
{followUpCount}
</span>
) : null}
</Link>
);
})}
</nav>
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Buka menu"
className="fixed left-4 top-3.5 z-40 flex size-10 items-center justify-center rounded-[12px] border border-ink-100 bg-surface text-ink-700 shadow-[var(--shadow-sm)] lg:hidden"
>
<Menu className="size-5" />
</button>
{open ? (
<div
className="fixed inset-0 z-40 bg-ink-900/40 backdrop-blur-sm lg:hidden"
onClick={() => setOpen(false)}
aria-hidden
/>
) : null}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-[248px] flex-col border-r border-ink-100 bg-surface transition-transform duration-200",
open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
)}
>
<div className="flex items-center justify-between px-5 py-5">
<Link href="/dashboard" className="flex items-center gap-2.5">
<span className="grad-primary flex size-9 items-center justify-center rounded-[12px] text-white shadow-[var(--shadow-brand)]">
<Code2 className="size-5" />
</span>
<span className="text-[19px] font-extrabold tracking-tight text-ink-900">Kodeva</span>
</Link>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Tutup menu"
className="text-ink-500 lg:hidden"
>
<X className="size-5" />
</button>
</div>
{nav}
<div className="border-t border-ink-100 p-3">
<div className="flex items-center gap-3 rounded-[14px] bg-canvas px-3 py-2.5">
<Avatar src={user.avatarUrl} name={user.name} size={36} />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold text-ink-900">{user.name}</p>
<p className="truncate text-[11px] text-ink-500">{user.role === "ADMIN" ? "Admin" : "Teacher"}</p>
</div>
</div>
</div>
</aside>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Bell, LogOut, Search } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { logoutAction } from "@/app/login/actions";
export function Topbar({ user }: { user: { name: string; email: string; role: string; avatarUrl?: string | null } }) {
return (
<header className="sticky top-0 z-30 border-b border-ink-100 bg-canvas/80 backdrop-blur-md">
<div className="flex h-16 items-center gap-3 px-4 pl-16 sm:px-6 sm:pl-16 lg:pl-6">
<form action="/students" className="relative hidden flex-1 max-w-md sm:block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-500" />
<input
name="q"
placeholder="Cari siswa, email, telepon..."
className="h-10 w-full rounded-[12px] border border-ink-300 bg-surface pl-9 pr-3 text-sm placeholder:text-ink-500 focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-500/25"
/>
</form>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
aria-label="Notifikasi"
className="relative flex size-10 items-center justify-center rounded-[12px] border border-ink-100 bg-surface text-ink-700 hover:bg-ink-100"
>
<Bell className="size-[18px]" />
<span className="absolute right-2.5 top-2.5 size-2 rounded-full bg-danger-500 ring-2 ring-surface" />
</button>
<div className="hidden items-center gap-3 rounded-[14px] border border-ink-100 bg-surface py-1.5 pl-1.5 pr-3 sm:flex">
<Avatar src={user.avatarUrl} name={user.name} size={30} />
<div className="leading-tight">
<p className="text-[12.5px] font-semibold text-ink-900">{user.name}</p>
<p className="text-[11px] text-ink-500">{user.role === "ADMIN" ? "Admin" : "Teacher"}</p>
</div>
</div>
<form action={logoutAction}>
<button
type="submit"
aria-label="Keluar"
className="flex size-10 items-center justify-center rounded-[12px] border border-ink-100 bg-surface text-ink-700 hover:bg-danger-100 hover:text-danger-500"
>
<LogOut className="size-[18px]" />
</button>
</form>
</div>
</div>
</header>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useActionState, useState } from "react";
import Link from "next/link";
import { AlertCircle, Save, Wallet, X } from "lucide-react";
import type { PaymentState } from "@/app/(app)/payments/actions";
import { Field, Input, Select, Textarea } from "@/components/ui/form";
import { RadioPills } from "@/components/ui/radio-pills";
import { SubmitButton } from "@/components/ui/submit-button";
import { Button } from "@/components/ui/button";
import { UploadInput } from "@/components/ui/upload-input";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { PAYMENT_METHODS, PAYMENT_METHOD_META, PAYMENT_STATUSES, PAYMENT_STATUS_META } from "@/lib/constants";
import { getPricePerMeeting } from "@/lib/prorata";
import { formatIDR } from "@/lib/utils";
type ClassOpt = { id: string; name: string; color: string | null; totalMeetings: number; packagePrice: number; pricePerMeeting: number | null };
type StudentOpt = { id: string; fullName: string };
export function PaymentForm({
action,
students,
classes,
defaultStudentId,
}: {
action: (prev: PaymentState, formData: FormData) => Promise<PaymentState>;
students: StudentOpt[];
classes: ClassOpt[];
defaultStudentId?: string;
}) {
const [state, formAction] = useActionState<PaymentState, FormData>(action, undefined);
const [classId, setClassId] = useState(classes[0]?.id ?? "");
const [meetingsPaid, setMeetingsPaid] = useState(10);
const cls = classes.find((c) => c.id === classId);
const ppm = cls ? getPricePerMeeting(cls) : 0;
const amount = ppm * Math.max(0, meetingsPaid);
return (
<form action={formAction} className="space-y-6">
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detail Pembayaran</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<Field label="Siswa" required>
<Select name="studentId" defaultValue={defaultStudentId ?? ""}>
<option value="">Pilih siswa...</option>
{students.map((s) => (
<option key={s.id} value={s.id}>
{s.fullName}
</option>
))}
</Select>
</Field>
<Field label="Kelas" required>
<Select name="classId" value={classId} onChange={(e) => setClassId(e.target.value)}>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name} {formatIDR(getPricePerMeeting(c))}/sesi
</option>
))}
</Select>
</Field>
<Field label="Jumlah pertemuan dibayar" required>
<Input name="meetingsPaid" type="number" min={1} max={100} value={meetingsPaid} onChange={(e) => setMeetingsPaid(Number(e.target.value) || 0)} />
</Field>
<Field label="Metode pembayaran" required>
<RadioPills
name="method"
defaultValue="TRANSFER"
options={PAYMENT_METHODS.map((m) => ({ value: m, label: PAYMENT_METHOD_META[m].label, color: PAYMENT_METHOD_META[m].color }))}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Tanggal bayar">
<Input name="paidAt" type="date" />
</Field>
<Field label="Status">
<Select name="status" defaultValue="PAID">
{PAYMENT_STATUSES.map((s) => (
<option key={s} value={s}>
{PAYMENT_STATUS_META[s].label}
</option>
))}
</Select>
</Field>
</div>
<Field label="Catatan">
<Textarea name="note" placeholder="Opsional, mis. cicilan 1 dari 2" />
</Field>
</CardBody>
</Card>
<div className="space-y-6">
<Card className="overflow-hidden">
<div className="grad-primary p-5 text-white">
<p className="flex items-center gap-2 text-[13px] text-white/85">
<Wallet className="size-4" /> Ringkasan Prorata
</p>
<p className="mt-2 font-num text-[34px] font-bold leading-tight">{formatIDR(amount)}</p>
<p className="text-[13px] text-white/85">
{meetingsPaid} pertemuan × {formatIDR(ppm)}
</p>
</div>
<CardBody className="grid grid-cols-3 gap-2 pt-4 text-center">
<div className="rounded-[12px] bg-canvas py-2.5">
<p className="text-[11px] text-ink-500">Per sesi</p>
<p className="font-num text-[14px] font-bold text-ink-900">{formatIDR(ppm)}</p>
</div>
<div className="rounded-[12px] bg-canvas py-2.5">
<p className="text-[11px] text-ink-500">Pertemuan</p>
<p className="font-num text-[14px] font-bold text-ink-900">{meetingsPaid}</p>
</div>
<div className="rounded-[12px] bg-canvas py-2.5">
<p className="text-[11px] text-ink-500">Total paket</p>
<p className="font-num text-[14px] font-bold text-ink-900">{cls ? formatIDR(cls.packagePrice) : "-"}</p>
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Bukti Pembayaran</CardTitle>
<span className="text-[12px] text-ink-500">Opsional</span>
</CardHeader>
<CardBody>
<UploadInput name="proofUrl" shape="rect" label="Upload bukti bayar" />
</CardBody>
</Card>
</div>
</div>
<div className="sticky bottom-4 flex items-center justify-end gap-2 rounded-[16px] border border-ink-100 bg-surface/90 px-4 py-3 shadow-[var(--shadow-md)] backdrop-blur">
<Link href="/payments">
<Button type="button" variant="outline">
<X className="size-4" /> Batal
</Button>
</Link>
<SubmitButton pendingText="Menyimpan...">
<Save className="size-4" /> Catat Pembayaran
</SubmitButton>
</div>
</form>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { AlertCircle, Save, X } from "lucide-react";
import type { PlacementState } from "@/app/(app)/placements/actions";
import { Field, Input, Select, Textarea } from "@/components/ui/form";
import { SubmitButton } from "@/components/ui/submit-button";
import { Button } from "@/components/ui/button";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { PLACEMENT_CATEGORIES } from "@/lib/constants";
export function PlacementForm({
action,
students,
classes,
examiners,
}: {
action: (prev: PlacementState, formData: FormData) => Promise<PlacementState>;
students: { id: string; fullName: string }[];
classes: { id: string; name: string }[];
examiners: { id: string; name: string }[];
}) {
const [state, formAction] = useActionState<PlacementState, FormData>(action, undefined);
return (
<form action={formAction} className="space-y-6">
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Data Tes</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<Field label="Siswa" required>
<Select name="studentId" defaultValue="">
<option value="">Pilih siswa...</option>
{students.map((s) => (
<option key={s.id} value={s.id}>
{s.fullName}
</option>
))}
</Select>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Tanggal tes">
<Input name="testDate" type="date" />
</Field>
<Field label="Pemeriksa">
<Select name="examinerId" defaultValue="">
<option value="">Pilih pemeriksa...</option>
{examiners.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</Select>
</Field>
</div>
<Field label="Rekomendasi kelas">
<Select name="recommendedClassId" defaultValue="">
<option value="">- Belum ditentukan -</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Field>
<Field label="Catatan">
<Textarea name="notes" placeholder="Catatan hasil tes..." />
</Field>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Skor Kategori</CardTitle>
<span className="text-[12px] text-ink-500">Skala 0100</span>
</CardHeader>
<CardBody className="space-y-3">
{PLACEMENT_CATEGORIES.map((category) => (
<div key={category} className="flex items-center justify-between gap-3">
<label className="text-[13.5px] font-medium text-ink-700">{category}</label>
<Input name={`scores.${category}`} type="number" min={0} max={100} defaultValue={60} className="w-24 text-center font-num" />
</div>
))}
<p className="rounded-[12px] bg-brand-50 px-3 py-2 text-[12px] text-brand-700">
Level hasil otomatis dari rata-rata: &lt;65 Beginner · 6584 Intermediate · 85 Advanced
</p>
</CardBody>
</Card>
</div>
<div className="sticky bottom-4 flex items-center justify-end gap-2 rounded-[16px] border border-ink-100 bg-surface/90 px-4 py-3 shadow-[var(--shadow-md)] backdrop-blur">
<Link href="/placements">
<Button type="button" variant="outline">
<X className="size-4" /> Batal
</Button>
</Link>
<SubmitButton pendingText="Menyimpan...">
<Save className="size-4" /> Simpan Hasil Tes
</SubmitButton>
</div>
</form>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { AlertCircle, Save, X } from "lucide-react";
import type { ReportState } from "@/app/(app)/report-cards/actions";
import { Field, Input, Select, Textarea } from "@/components/ui/form";
import { SubmitButton } from "@/components/ui/submit-button";
import { Button } from "@/components/ui/button";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { REPORT_ASPECTS } from "@/lib/constants";
export function ReportForm({
action,
students,
classes,
}: {
action: (prev: ReportState, formData: FormData) => Promise<ReportState>;
students: { id: string; fullName: string }[];
classes: { id: string; name: string }[];
}) {
const [state, formAction] = useActionState<ReportState, FormData>(action, undefined);
return (
<form action={formAction} className="space-y-6">
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Data Raport</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<Field label="Siswa" required>
<Select name="studentId" defaultValue="">
<option value="">Pilih siswa...</option>
{students.map((s) => (
<option key={s.id} value={s.id}>
{s.fullName}
</option>
))}
</Select>
</Field>
<Field label="Kelas" required>
<Select name="classId" defaultValue="">
<option value="">Pilih kelas...</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Field>
<Field label="Periode" required hint="Mis. 2026-Term1">
<Input name="period" placeholder="2026-Term1" />
</Field>
<Field label="Ringkasan kehadiran">
<Input name="attendanceSummary" placeholder="Hadir 8 dari 10 pertemuan" />
</Field>
<Field label="Catatan guru">
<Textarea name="teacherNotes" placeholder="Kesimpulan perkembangan siswa..." />
</Field>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Penilaian Aspek</CardTitle>
<span className="text-[12px] text-ink-500">Skala 0100</span>
</CardHeader>
<CardBody className="space-y-3">
{REPORT_ASPECTS.map((aspect) => (
<div key={aspect} className="flex items-center justify-between gap-3">
<label className="text-[13.5px] font-medium text-ink-700">{aspect}</label>
<Input name={`scores.${aspect}`} type="number" min={0} max={100} defaultValue={80} className="w-24 text-center font-num" />
</div>
))}
</CardBody>
</Card>
</div>
<div className="sticky bottom-4 flex items-center justify-end gap-2 rounded-[16px] border border-ink-100 bg-surface/90 px-4 py-3 shadow-[var(--shadow-md)] backdrop-blur">
<Link href="/report-cards">
<Button type="button" variant="outline">
<X className="size-4" /> Batal
</Button>
</Link>
<SubmitButton pendingText="Menyimpan...">
<Save className="size-4" /> Simpan Raport
</SubmitButton>
</div>
</form>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Badge, Chip } from "@/components/ui/badge";
import {
ATTENDANCE_META,
LEAD_SOURCE_META,
LEVEL_META,
PAYMENT_METHOD_META,
PAYMENT_STATUS_META,
STUDENT_STATUS_META,
} from "@/lib/constants";
export function StatusBadge({ status }: { status: string }) {
const m = STUDENT_STATUS_META[status] ?? { label: status, bg: "#EEF1F7", text: "#4A5470", color: "#6B7590" };
return (
<Badge bg={m.bg} fg={m.text} dot={m.color}>
{m.label}
</Badge>
);
}
export function SourceChip({ source }: { source: string }) {
const m = LEAD_SOURCE_META[source];
if (!m) return <Chip>{source}</Chip>;
return <Chip color={m.color}>{m.label}</Chip>;
}
export function AttendanceBadge({ status }: { status: string }) {
const m = ATTENDANCE_META[status] ?? { label: status, bg: "#EEF1F7", text: "#4A5470", color: "#6B7590" };
return (
<Badge bg={m.bg} fg={m.text} dot={m.color}>
{m.label}
</Badge>
);
}
export function LevelBadge({ level }: { level: string }) {
const m = LEVEL_META[level] ?? { label: level, bg: "#EEF1F7", text: "#4A5470", color: "#6B7590" };
return (
<Badge bg={m.bg} fg={m.text} dot={m.color}>
{m.label}
</Badge>
);
}
export function MethodChip({ method }: { method: string }) {
const m = PAYMENT_METHOD_META[method] ?? { label: method, color: "#6B7590" };
return <Chip color={m.color}>{m.label}</Chip>;
}
export function PaymentStatusBadge({ status }: { status: string }) {
const m = PAYMENT_STATUS_META[status] ?? { label: status, bg: "#EEF1F7", text: "#4A5470" };
return (
<Badge bg={m.bg} fg={m.text}>
{m.label}
</Badge>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useActionState } from "react";
import { AlertCircle, Save, X } from "lucide-react";
import Link from "next/link";
import type { ActionState } from "@/app/(app)/students/actions";
import { Field, Input, Select, Textarea } from "@/components/ui/form";
import { RadioPills } from "@/components/ui/radio-pills";
import { SubmitButton } from "@/components/ui/submit-button";
import { UploadInput } from "@/components/ui/upload-input";
import { Button } from "@/components/ui/button";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { LEAD_SOURCES, STUDENT_STATUSES, LEAD_SOURCE_META, STUDENT_STATUS_META } from "@/lib/constants";
type ClassOption = { id: string; name: string; code: string; level: string; color: string | null };
export type StudentInitial = {
id?: string;
fullName?: string;
phone?: string;
email?: string | null;
photoUrl?: string | null;
parentName?: string | null;
parentPhone?: string | null;
gender?: string | null;
birthDate?: string | null;
address?: string | null;
leadSource?: string;
status?: string;
notes?: string | null;
classIds?: string[];
};
export function StudentForm({
action,
classes,
initial,
mode,
}: {
action: (prev: ActionState, formData: FormData) => Promise<ActionState>;
classes: ClassOption[];
initial?: StudentInitial;
mode: "create" | "edit";
}) {
const [state, formAction] = useActionState<ActionState, FormData>(action, undefined);
return (
<form action={formAction} className="space-y-6">
{initial?.id ? <input type="hidden" name="id" value={initial.id} /> : null}
{state?.error ? (
<div className="flex items-center gap-2 rounded-[12px] bg-danger-100 px-3 py-2.5 text-[13px] font-medium text-danger-500">
<AlertCircle className="size-4 shrink-0" />
{state.error}
</div>
) : null}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Data Siswa</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<UploadInput name="photoUrl" defaultValue={initial?.photoUrl} label="Upload foto profil" />
<Field label="Nama lengkap" required>
<Input name="fullName" defaultValue={initial?.fullName} placeholder="Mis. Andi Pratama" />
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="No. telepon" required>
<Input name="phone" defaultValue={initial?.phone} placeholder="0812-xxxx-xxxx" />
</Field>
<Field label="Email">
<Input name="email" type="email" defaultValue={initial?.email ?? ""} placeholder="nama@email.com" />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Nama orang tua">
<Input name="parentName" defaultValue={initial?.parentName ?? ""} placeholder="Mis. Budi Pratama" />
</Field>
<Field label="No. telepon orang tua">
<Input name="parentPhone" defaultValue={initial?.parentPhone ?? ""} placeholder="0812-xxxx-xxxx" />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Jenis kelamin">
<Select name="gender" defaultValue={initial?.gender ?? ""}>
<option value="">Pilih...</option>
<option value="MALE">Laki-laki</option>
<option value="FEMALE">Perempuan</option>
</Select>
</Field>
<Field label="Tanggal lahir">
<Input name="birthDate" type="date" defaultValue={initial?.birthDate ?? ""} />
</Field>
</div>
<Field label="Alamat">
<Textarea name="address" defaultValue={initial?.address ?? ""} placeholder="Alamat domisili" />
</Field>
</CardBody>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Status & Sumber</CardTitle>
</CardHeader>
<CardBody className="space-y-4">
<Field label="Tahu dari mana?" required hint="Sumber lead siswa ini.">
<RadioPills
name="leadSource"
columns
defaultValue={initial?.leadSource ?? "WHATSAPP"}
options={LEAD_SOURCES.map((s) => ({ value: s, label: LEAD_SOURCE_META[s].label, color: LEAD_SOURCE_META[s].color }))}
/>
</Field>
<Field label="Status siswa" required>
<RadioPills
name="status"
defaultValue={initial?.status ?? "CALON_STUDENT"}
options={STUDENT_STATUSES.map((s) => ({ value: s, label: STUDENT_STATUS_META[s].label, color: STUDENT_STATUS_META[s].color }))}
/>
</Field>
<Field label="Catatan">
<Textarea name="notes" defaultValue={initial?.notes ?? ""} placeholder="Catatan tambahan tentang siswa" />
</Field>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Pilih Kelas</CardTitle>
<span className="text-[12px] text-ink-500">{classes.length} kelas tersedia</span>
</CardHeader>
<CardBody className="space-y-2">
{classes.map((c) => (
<label key={c.id} className="flex cursor-pointer items-center gap-3 rounded-[12px] border border-ink-100 px-3 py-2.5 hover:bg-canvas">
<input
type="checkbox"
name="classIds"
value={c.id}
defaultChecked={initial?.classIds?.includes(c.id)}
className="size-4 accent-[var(--color-brand-500)]"
/>
<span className="size-2.5 rounded-full" style={{ backgroundColor: c.color ?? "#7C5CFC" }} />
<span className="flex-1 text-[13.5px] font-medium text-ink-900">{c.name}</span>
<span className="font-mono text-[11px] text-ink-500">{c.code}</span>
</label>
))}
<p className="text-[12px] text-ink-500">Kelas hanya tersimpan otomatis untuk status Student.</p>
</CardBody>
</Card>
</div>
</div>
<div className="sticky bottom-4 flex items-center justify-end gap-2 rounded-[16px] border border-ink-100 bg-surface/90 px-4 py-3 shadow-[var(--shadow-md)] backdrop-blur">
<Link href={mode === "edit" && initial?.id ? `/students/${initial.id}` : "/students"}>
<Button type="button" variant="outline">
<X className="size-4" /> Batal
</Button>
</Link>
<SubmitButton pendingText="Menyimpan...">
<Save className="size-4" /> {mode === "create" ? "Simpan Siswa" : "Simpan Perubahan"}
</SubmitButton>
</div>
</form>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { cn, initials } from "@/lib/utils";
export function Avatar({
src,
name,
size = 40,
className,
ring,
}: {
src?: string | null;
name: string;
size?: number;
className?: string;
ring?: boolean;
}) {
const style = { width: size, height: size };
return (
<span
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-brand-100 text-[13px] font-semibold text-brand-700",
ring && "ring-2 ring-brand-500 ring-offset-2 ring-offset-surface",
className,
)}
style={style}
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={name} className="h-full w-full object-cover" />
) : (
initials(name)
)}
</span>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
bg?: string;
fg?: string;
dot?: string;
};
export function Badge({ className, bg, fg, dot, children, style, ...props }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[12px] font-semibold",
!bg && "bg-ink-100 text-ink-700",
className,
)}
style={{ backgroundColor: bg, color: fg, ...style }}
{...props}
>
{dot ? <span className="size-1.5 rounded-full" style={{ backgroundColor: dot }} /> : null}
{children}
</span>
);
}
export function Chip({ className, color, children, ...props }: HTMLAttributes<HTMLSpanElement> & { color?: string }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium text-ink-700",
className,
)}
style={color ? { borderColor: `${color}55`, backgroundColor: `${color}14` } : undefined}
{...props}
>
{color ? <span className="size-1.5 rounded-full" style={{ backgroundColor: color }} /> : null}
{children}
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 whitespace-nowrap",
{
variants: {
variant: {
primary: "grad-primary text-white shadow-[var(--shadow-brand)] hover:-translate-y-px hover:brightness-105",
secondary: "bg-brand-50 text-brand-700 hover:bg-brand-100",
outline: "border border-ink-300 bg-surface text-ink-900 hover:bg-ink-100",
ghost: "text-ink-700 hover:bg-ink-100",
danger: "bg-danger-500 text-white hover:brightness-105",
coral: "grad-coral text-white hover:-translate-y-px",
},
size: {
sm: "h-9 rounded-[10px] px-3 text-[13px]",
md: "h-10 rounded-[12px] px-4 text-sm",
lg: "h-12 rounded-[14px] px-5 text-[15px]",
icon: "h-10 w-10 rounded-[12px]",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+31
View File
@@ -0,0 +1,31 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-[20px] border border-ink-100 bg-surface shadow-[var(--shadow-sm)]", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center justify-between gap-3 px-5 pt-5 pb-3", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-[15px] font-semibold text-ink-900", className)} {...props} />;
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-[13px] text-ink-500", className)} {...props} />;
}
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-5 pb-5", className)} {...props} />;
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center gap-2 border-t border-ink-100 px-5 py-4", className)} {...props} />;
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
export function EmptyState({
icon,
title,
description,
action,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-[20px] border border-dashed border-ink-300 bg-surface px-6 py-12 text-center">
{icon ? (
<span className="grad-primary flex size-14 items-center justify-center rounded-2xl text-white opacity-90">
{icon}
</span>
) : null}
<div>
<h3 className="text-[16px] font-semibold text-ink-900">{title}</h3>
{description ? <p className="mt-1 max-w-sm text-[13px] text-ink-500">{description}</p> : null}
</div>
{action}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { InputHTMLAttributes, LabelHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
return <label className={cn("mb-1.5 block text-[13px] font-medium text-ink-700", className)} {...props} />;
}
const baseField =
"w-full rounded-[12px] border border-ink-300 bg-surface px-3 text-sm text-ink-900 placeholder:text-ink-500 transition-colors focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-500/25 disabled:bg-ink-100";
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(baseField, "h-10", className)} {...props} />;
}
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(baseField, "min-h-[88px] py-2.5", className)} {...props} />;
}
export function Select({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={cn(baseField, "h-10 appearance-none bg-[length:16px] pr-9", className)} {...props}>
{children}
</select>
);
}
export function Field({
label,
required,
hint,
error,
children,
className,
}: {
label?: string;
required?: boolean;
hint?: string;
error?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
{label ? (
<Label>
{label} {required ? <span className="text-danger-500">*</span> : null}
</Label>
) : null}
{children}
{error ? (
<p className="mt-1 text-[12px] text-danger-500">{error}</p>
) : hint ? (
<p className="mt-1 text-[12px] text-ink-500">{hint}</p>
) : null}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useRef, useState } from "react";
import { ImagePlus, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function MultiUpload({ name }: { name: string }) {
const [urls, setUrls] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
setError(null);
const next: string[] = [];
for (const file of files) {
try {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body });
const json = await res.json();
if (!res.ok) throw new Error(json?.error?.message ?? "Gagal upload.");
next.push(json.data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "Gagal upload.");
}
}
setUrls((p) => [...p, ...next]);
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
function remove(url: string) {
setUrls((p) => p.filter((u) => u !== url));
}
return (
<div>
{urls.map((u) => (
<input key={u} type="hidden" name={name} value={u} />
))}
<div className="flex flex-wrap gap-3">
{urls.map((u, i) => (
<div key={u} className="group relative h-24 w-32 overflow-hidden rounded-[14px] border border-ink-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={u} alt={`foto ${i + 1}`} className="h-full w-full object-cover" />
<button
type="button"
onClick={() => remove(u)}
className="absolute right-1 top-1 flex size-6 items-center justify-center rounded-full bg-ink-900/60 text-white hover:bg-ink-900"
aria-label="Hapus foto"
>
<X className="size-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => inputRef.current?.click()}
className={cn(
"flex h-24 w-32 flex-col items-center justify-center gap-1 rounded-[14px] border border-dashed border-ink-300 bg-canvas text-ink-500 transition-colors hover:border-brand-500 hover:text-brand-600",
)}
>
{uploading ? <Loader2 className="size-5 animate-spin" /> : <ImagePlus className="size-5" />}
<span className="text-[11.5px]">{uploading ? "Mengunggah..." : "Tambah foto"}</span>
</button>
</div>
{error ? <p className="mt-1 text-[12px] text-danger-500">{error}</p> : null}
<p className="mt-1 text-[12px] text-ink-500">JPG/PNG/WebP, maks 5MB per foto.</p>
<input ref={inputRef} type="file" accept="image/*" multiple className="hidden" onChange={onFiles} />
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function PageHeader({
title,
description,
actions,
className,
}: {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", className)}>
<div>
<h1 className="text-[26px] font-extrabold leading-tight text-ink-900">{title}</h1>
{description ? <p className="mt-1 text-[13px] text-ink-500">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { cn } from "@/lib/utils";
export type RadioOption = { value: string; label: string; color?: string; icon?: string };
/** Pilihan berbentuk pill/segmented berbasis radio — tanpa JavaScript, aman untuk form server action. */
export function RadioPills({
name,
options,
defaultValue,
className,
columns,
}: {
name: string;
options: RadioOption[];
defaultValue?: string;
className?: string;
columns?: boolean;
}) {
return (
<div className={cn(columns ? "grid grid-cols-2 gap-2" : "flex flex-wrap gap-2", className)}>
{options.map((opt) => (
<label key={opt.value} className="cursor-pointer">
<input
type="radio"
name={name}
value={opt.value}
defaultChecked={defaultValue === opt.value}
className="peer sr-only"
/>
<span
className={cn(
"inline-flex w-full items-center justify-center gap-2 rounded-[12px] border border-ink-300 bg-surface px-3 py-2 text-[13px] font-medium text-ink-700 transition-all",
"hover:border-brand-300 hover:bg-brand-50",
"peer-checked:border-brand-500 peer-checked:bg-brand-50 peer-checked:text-brand-700 peer-checked:ring-2 peer-checked:ring-brand-500/20",
)}
>
{opt.color ? <span className="size-2 rounded-full" style={{ backgroundColor: opt.color }} /> : null}
{opt.label}
</span>
</label>
))}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
export function StatCard({
label,
value,
icon,
gradient = "grad-primary",
delta,
hint,
href,
}: {
label: string;
value: ReactNode;
icon: ReactNode;
gradient?: string;
delta?: string;
hint?: string;
href?: string;
}) {
const body = (
<div className="flex h-full items-center gap-4 rounded-[20px] border border-ink-100 bg-surface p-4 shadow-[var(--shadow-sm)] transition-all duration-150 hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]">
<span className={cn("flex size-11 shrink-0 items-center justify-center rounded-[14px] text-white", gradient)}>
{icon}
</span>
<div className="min-w-0">
<p className="truncate text-[12px] font-medium text-ink-500">{label}</p>
<div className="flex items-baseline gap-2">
<p className="font-num text-[26px] font-bold leading-tight text-ink-900">{value}</p>
{delta ? <span className="text-[12px] font-semibold text-mint-500">{delta}</span> : null}
</div>
{hint ? <p className="truncate text-[11px] text-ink-500">{hint}</p> : null}
</div>
</div>
);
if (href) return <Link href={href}>{body}</Link>;
return body;
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { useFormStatus } from "react-dom";
import { Loader2 } from "lucide-react";
import { Button, type ButtonProps } from "./button";
export function SubmitButton({ children, pendingText, ...props }: ButtonProps & { pendingText?: string }) {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} {...props}>
{pending ? <Loader2 className="size-4 animate-spin" /> : null}
{pending ? (pendingText ?? "Menyimpan...") : children}
</Button>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRef, useState } from "react";
import { Camera, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function UploadInput({
name,
defaultValue,
shape = "circle",
label = "Upload foto",
className,
}: {
name: string;
defaultValue?: string | null;
shape?: "circle" | "rect";
label?: string;
className?: string;
}) {
const [value, setValue] = useState(defaultValue ?? "");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError(null);
try {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body });
const json = await res.json();
if (!res.ok) throw new Error(json?.error?.message ?? "Gagal upload.");
setValue(json.data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "Gagal upload.");
} finally {
setUploading(false);
}
}
return (
<div className={cn("flex items-center gap-3", className)}>
<input type="hidden" name={name} value={value} />
<button
type="button"
onClick={() => inputRef.current?.click()}
className={cn(
"relative flex shrink-0 items-center justify-center overflow-hidden border border-dashed border-ink-300 bg-canvas text-ink-500 transition-colors hover:border-brand-500 hover:text-brand-600",
shape === "circle" ? "size-20 rounded-full" : "h-20 w-28 rounded-[14px]",
)}
>
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="preview" className="h-full w-full object-cover" />
) : uploading ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Camera className="size-5" />
)}
</button>
<div className="min-w-0">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-[13px] font-semibold text-brand-600 hover:underline"
>
{value ? "Ganti foto" : label}
</button>
<p className="text-[12px] text-ink-500">JPG/PNG/WebP, maks 5MB.</p>
{value ? (
<button
type="button"
onClick={() => setValue("")}
className="mt-0.5 inline-flex items-center gap-1 text-[12px] text-danger-500 hover:underline"
>
<X className="size-3" /> Hapus
</button>
) : null}
{error ? <p className="text-[12px] text-danger-500">{error}</p> : null}
</div>
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
export const SESSION_COOKIE = "kodeva_session";
export type SessionUser = {
userId: string;
name: string;
email: string;
role: "ADMIN" | "TEACHER";
};
const secret = () =>
new TextEncoder().encode(process.env.AUTH_SECRET ?? "kodeva-dev-secret-change-me-32characters-min");
export async function signSession(user: SessionUser) {
return new SignJWT({ ...user })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secret());
}
export async function verifySession(token: string): Promise<SessionUser | null> {
try {
const { payload } = await jwtVerify(token, secret());
if (!payload.userId) return null;
return {
userId: String(payload.userId),
name: String(payload.name),
email: String(payload.email),
role: payload.role === "ADMIN" ? "ADMIN" : "TEACHER",
};
} catch {
return null;
}
}
export async function getSession(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
return verifySession(token);
}
export async function setSessionCookie(user: SessionUser) {
const token = await signSession(user);
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
export async function clearSessionCookie() {
const store = await cookies();
store.delete(SESSION_COOKIE);
}
+101
View File
@@ -0,0 +1,101 @@
// Konstanta domain — nilai enum disimpan sebagai String di SQLite (lihat prisma/schema.prisma)
export const STUDENT_STATUSES = ["CALON_STUDENT", "STUDENT", "EX_STUDENT"] as const;
export type StudentStatus = (typeof STUDENT_STATUSES)[number];
export const LEAD_SOURCES = ["WHATSAPP", "REFERRAL", "BANNER", "SOCIAL_MEDIA"] as const;
export type LeadSource = (typeof LEAD_SOURCES)[number];
export const FOLLOW_UP_STATUSES = [
"NEW",
"CONTACTED",
"TRIAL",
"OFFER_SENT",
"ENROLLED",
"LOST",
] as const;
export type FollowUpStatus = (typeof FOLLOW_UP_STATUSES)[number];
export const ATTENDANCE_STATUSES = ["PRESENT", "LATE", "EXCUSED", "SICK", "ABSENT"] as const;
export type AttendanceStatus = (typeof ATTENDANCE_STATUSES)[number];
export const PAYMENT_METHODS = ["CASH", "TRANSFER", "QRIS"] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
export const PAYMENT_STATUSES = ["UNPAID", "PARTIAL", "PAID", "REFUNDED"] as const;
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const LEVELS = ["BEGINNER", "INTERMEDIATE", "ADVANCED"] as const;
export type Level = (typeof LEVELS)[number];
export const CLASS_STATUSES = ["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"] as const;
export const REPORT_ASPECTS = [
"Logika",
"Computational Thinking",
"Problem Solving",
"Kreativitas",
"Kolaborasi",
] as const;
export const PLACEMENT_CATEGORIES = [
"Logika",
"Dasar Coding",
"Problem Solving",
"Kreativitas",
] as const;
// ── Label & warna ─────────────────────────────────────────
export const STUDENT_STATUS_META: Record<string, { label: string; color: string; bg: string; text: string }> = {
CALON_STUDENT: { label: "Calon Student", color: "#FFB020", bg: "#FFF4E0", text: "#9A6100" },
STUDENT: { label: "Student", color: "#12B886", bg: "#E4F8F1", text: "#0A7A58" },
EX_STUDENT: { label: "Ex Student", color: "#6B7590", bg: "#EEF1F7", text: "#4A5470" },
};
export const LEAD_SOURCE_META: Record<string, { label: string; color: string }> = {
WHATSAPP: { label: "WhatsApp", color: "#22C55E" },
REFERRAL: { label: "Referral", color: "#7C5CFC" },
BANNER: { label: "Banner", color: "#FFB020" },
SOCIAL_MEDIA: { label: "Social Media", color: "#2AA9FF" },
};
export const FOLLOW_UP_META: Record<string, { label: string; color: string }> = {
NEW: { label: "Baru", color: "#94A3B8" },
CONTACTED: { label: "Dihubungi", color: "#2AA9FF" },
TRIAL: { label: "Trial", color: "#7C5CFC" },
OFFER_SENT: { label: "Penawaran", color: "#FFB020" },
ENROLLED: { label: "Enrolled", color: "#12B886" },
LOST: { label: "Lost", color: "#F04438" },
};
export const ATTENDANCE_META: Record<string, { label: string; short: string; color: string; bg: string; text: string }> = {
PRESENT: { label: "Hadir", short: "H", color: "#12B886", bg: "#E4F8F1", text: "#0A7A58" },
LATE: { label: "Terlambat", short: "T", color: "#FFB020", bg: "#FFF4E0", text: "#9A6100" },
EXCUSED: { label: "Izin", short: "I", color: "#2AA9FF", bg: "#E3F3FF", text: "#0B6FB8" },
SICK: { label: "Sakit", short: "S", color: "#A78BFA", bg: "#F1ECFE", text: "#5B34C9" },
ABSENT: { label: "Alpa", short: "A", color: "#F04438", bg: "#FEE4E2", text: "#B42318" },
};
export const PAYMENT_METHOD_META: Record<string, { label: string; color: string }> = {
CASH: { label: "Cash", color: "#12B886" },
TRANSFER: { label: "Transfer", color: "#2AA9FF" },
QRIS: { label: "QRIS", color: "#7C5CFC" },
};
export const LEVEL_META: Record<string, { label: string; color: string; bg: string; text: string }> = {
BEGINNER: { label: "Beginner", color: "#12B886", bg: "#E4F8F1", text: "#0A7A58" },
INTERMEDIATE: { label: "Intermediate", color: "#2AA9FF", bg: "#E3F3FF", text: "#0B6FB8" },
ADVANCED: { label: "Advanced", color: "#A78BFA", bg: "#F1ECFE", text: "#5B34C9" },
};
export const PAYMENT_STATUS_META: Record<string, { label: string; color: string; bg: string; text: string }> = {
UNPAID: { label: "Belum Bayar", color: "#F04438", bg: "#FEE4E2", text: "#B42318" },
PARTIAL: { label: "Sebagian", color: "#FFB020", bg: "#FFF4E0", text: "#9A6100" },
PAID: { label: "Lunas", color: "#12B886", bg: "#E4F8F1", text: "#0A7A58" },
REFUNDED: { label: "Refund", color: "#6B7590", bg: "#EEF1F7", text: "#4A5470" },
};
export function labelOf(meta: Record<string, { label: string }>, key: string | null | undefined, fallback = "-") {
if (!key) return fallback;
return meta[key]?.label ?? key;
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+74
View File
@@ -0,0 +1,74 @@
/**
* Logika prorata pembayaran — PURE FUNCTION (mudah di-unit-test).
* Aturan: AGENTS.md §7.1
*/
export type ClassPricing = {
packagePrice: number;
totalMeetings: number;
pricePerMeeting?: number | null;
};
/** Harga per pertemuan. Override `pricePerMeeting` bila diisi, jika tidak packagePrice/totalMeetings. */
export function getPricePerMeeting(cls: ClassPricing): number {
if (cls.pricePerMeeting && cls.pricePerMeeting > 0) return cls.pricePerMeeting;
if (!cls.totalMeetings || cls.totalMeetings <= 0) return 0;
return Math.round(cls.packagePrice / cls.totalMeetings);
}
/** Nominal pembayaran = jumlah pertemuan dibayar × harga per pertemuan. */
export function calcAmount(meetingsPaid: number, pricePerMeeting: number): number {
return Math.max(0, Math.floor(meetingsPaid)) * Math.max(0, Math.floor(pricePerMeeting));
}
export type Prorata = {
paidMeetings: number;
usedMeetings: number;
remainingMeetings: number;
pricePerMeeting: number;
paidValue: number;
usedValue: number;
remainingValue: number;
overQuota: boolean;
};
/** Ringkasan kuota sesi & nilai prorata (dasar refund/sisa). */
export function summarizeProrata(
paidMeetings: number,
usedMeetings: number,
pricePerMeeting: number,
): Prorata {
const remaining = Math.max(0, paidMeetings - usedMeetings);
return {
paidMeetings,
usedMeetings,
remainingMeetings: remaining,
pricePerMeeting,
paidValue: paidMeetings * pricePerMeeting,
usedValue: usedMeetings * pricePerMeeting,
remainingValue: remaining * pricePerMeeting,
overQuota: usedMeetings > paidMeetings,
};
}
/** Status pembayaran dari komitmen vs jumlah pertemuan terbayar. */
export function derivePaymentStatus(paidMeetings: number, committedMeetings: number): "UNPAID" | "PARTIAL" | "PAID" {
if (paidMeetings <= 0) return "UNPAID";
if (committedMeetings > 0 && paidMeetings < committedMeetings) return "PARTIAL";
return "PAID";
}
/** Grade dari rata-rata skor 0-100. */
export function gradeFromScore(avg: number): "A" | "B" | "C" | "D" {
if (avg >= 90) return "A";
if (avg >= 80) return "B";
if (avg >= 70) return "C";
return "D";
}
/** Level placement dari rata-rata skor. */
export function levelFromScore(avg: number): "BEGINNER" | "INTERMEDIATE" | "ADVANCED" {
if (avg >= 85) return "ADVANCED";
if (avg >= 65) return "INTERMEDIATE";
return "BEGINNER";
}
+106
View File
@@ -0,0 +1,106 @@
import { prisma } from "./prisma";
import { LEAD_SOURCE_META } from "./constants";
function endOfToday() {
const d = new Date();
d.setHours(23, 59, 59, 999);
return d;
}
export async function getFollowUpDueCount() {
return prisma.followUp.count({
where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: endOfToday() } },
});
}
export async function getDashboardSummary() {
const [activeStudents, exStudents, calonStudents, totalStudents] = await Promise.all([
prisma.student.count({ where: { status: "STUDENT" } }),
prisma.student.count({ where: { status: "EX_STUDENT" } }),
prisma.student.count({ where: { status: "CALON_STUDENT" } }),
prisma.student.count({ where: { archivedAt: null } }),
]);
// Tren siswa aktif (kumulatif) 6 bulan terakhir
const now = new Date();
const activeTrend: { month: string; value: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59);
const count = await prisma.student.count({
where: { status: "STUDENT", joinedAt: { lte: d } },
});
activeTrend.push({
month: new Date(now.getFullYear(), now.getMonth() - i, 1).toLocaleDateString("id-ID", { month: "short" }),
value: count,
});
}
// Distribusi sumber lead
const leadGroup = await prisma.student.groupBy({ by: ["leadSource"], _count: { _all: true } });
const leadSources = Object.entries(LEAD_SOURCE_META).map(([key, meta]) => ({
label: meta.label,
value: leadGroup.find((g) => g.leadSource === key)?._count._all ?? 0,
color: meta.color,
}));
// Kehadiran (seluruh data)
const attGroup = await prisma.attendance.groupBy({ by: ["status"], _count: { _all: true } });
const att = (s: string) => attGroup.find((g) => g.status === s)?._count._all ?? 0;
const present = att("PRESENT");
const late = att("LATE");
const excused = att("EXCUSED");
const sick = att("SICK");
const absent = att("ABSENT");
const totalAtt = present + late + excused + sick + absent;
const attendanceRate = totalAtt ? Math.round(((present + late) / totalAtt) * 100) : 0;
// List follow up due
const followUps = await prisma.followUp.findMany({
where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: endOfToday() } },
include: { student: true, pic: true },
orderBy: { nextFollowUpAt: "asc" },
take: 5,
});
// Kelas aktif + jumlah siswa
const classes = await prisma.class.findMany({
where: { status: "ACTIVE" },
include: { teacher: true, _count: { select: { enrollments: { where: { status: "ACTIVE" } } } } },
orderBy: { name: "asc" },
take: 5,
});
return {
counts: { activeStudents, exStudents, calonStudents, totalStudents },
activeTrend,
leadSources,
attendance: { present, late, absent: absent + excused + sick, rate: attendanceRate, total: totalAtt },
followUps,
classes,
followUpDue: followUps.length,
};
}
export async function getTeacherOptions() {
return prisma.user.findMany({
where: { role: "TEACHER", isActive: true },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
}
export async function getClassOptions() {
return prisma.class.findMany({
where: { status: { not: "ARCHIVED" } },
select: { id: true, name: true, code: true, level: true, color: true, totalMeetings: true, packagePrice: true, pricePerMeeting: true },
orderBy: { name: "asc" },
});
}
export async function getStudentOptions() {
return prisma.student.findMany({
where: { archivedAt: null },
select: { id: true, fullName: true, status: true, photoUrl: true },
orderBy: { fullName: "asc" },
});
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getSession, type SessionUser } from "./auth";
export async function requireUser(): Promise<SessionUser> {
const user = await getSession();
if (!user) redirect("/login");
return user;
}
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== "ADMIN") redirect("/dashboard");
return user;
}
export function isAdmin(user: SessionUser | null) {
return user?.role === "ADMIN";
}
+52
View File
@@ -0,0 +1,52 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { format, formatDistanceToNowStrict, isToday, isYesterday } from "date-fns";
import { id as localeID } from "date-fns/locale";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatIDR(value: number | null | undefined) {
if (value == null) return "-";
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0,
}).format(value);
}
export function formatNumber(value: number | null | undefined) {
if (value == null) return "0";
return new Intl.NumberFormat("id-ID").format(value);
}
export function formatDateID(date: Date | string | null | undefined, pattern = "d MMM yyyy") {
if (!date) return "-";
return format(new Date(date), pattern, { locale: localeID });
}
export function formatDateTimeID(date: Date | string | null | undefined) {
return formatDateID(date, "d MMM yyyy, HH:mm");
}
export function formatRelativeID(date: Date | string | null | undefined) {
if (!date) return "-";
const d = new Date(date);
if (isToday(d)) return "Hari ini";
if (isYesterday(d)) return "Kemarin";
return formatDistanceToNowStrict(d, { locale: localeID, addSuffix: true });
}
export function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0]?.toUpperCase())
.join("");
}
export function dayNameID(date: Date | string) {
return format(new Date(date), "EEEE", { locale: localeID });
}
+117
View File
@@ -0,0 +1,117 @@
import { z } from "zod";
import {
ATTENDANCE_STATUSES,
CLASS_STATUSES,
FOLLOW_UP_STATUSES,
LEAD_SOURCES,
LEVELS,
PAYMENT_METHODS,
PAYMENT_STATUSES,
STUDENT_STATUSES,
} from "./constants";
const optionalString = z.string().trim().optional().or(z.literal(""));
const optionalEmail = z.union([z.string().email("Email tidak valid"), z.literal("")]).optional();
const toIdList = z
.union([z.string(), z.array(z.string())])
.optional()
.transform((v) => (Array.isArray(v) ? v : v ? [v] : []));
export const studentSchema = z.object({
fullName: z.string().trim().min(2, "Nama minimal 2 karakter"),
phone: z.string().trim().min(6, "Nomor telepon tidak valid"),
email: optionalEmail,
photoUrl: optionalString,
parentName: optionalString,
parentPhone: optionalString,
gender: z.enum(["MALE", "FEMALE"]).optional().or(z.literal("")),
birthDate: optionalString,
address: optionalString,
leadSource: z.enum(LEAD_SOURCES),
status: z.enum(STUDENT_STATUSES),
notes: optionalString,
classIds: toIdList,
});
export type StudentInput = z.infer<typeof studentSchema>;
export const classSchema = z.object({
name: z.string().trim().min(2, "Nama kelas minimal 2 karakter"),
code: z.string().trim().min(2, "Kode kelas wajib diisi").toUpperCase(),
description: optionalString,
level: z.enum(LEVELS),
teacherId: optionalString,
totalMeetings: z.coerce.number().int().min(1, "Minimal 1 pertemuan").max(100),
packagePrice: z.coerce.number().int().min(0),
pricePerMeeting: z.coerce.number().int().min(0).optional(),
capacity: z.coerce.number().int().min(1).max(100),
schedule: optionalString,
color: optionalString,
status: z.enum(CLASS_STATUSES),
startDate: optionalString,
});
export type ClassInput = z.infer<typeof classSchema>;
export const paymentSchema = z.object({
studentId: z.string().min(1, "Pilih siswa"),
classId: z.string().min(1, "Pilih kelas"),
meetingsPaid: z.coerce.number().int().min(1, "Minimal 1 pertemuan").max(100),
method: z.enum(PAYMENT_METHODS),
status: z.enum(PAYMENT_STATUSES).default("PAID"),
paidAt: optionalString,
note: optionalString,
proofUrl: optionalString,
});
export type PaymentInput = z.infer<typeof paymentSchema>;
export const followUpSchema = z.object({
studentId: z.string().min(1, "Pilih siswa").optional(),
status: z.enum(FOLLOW_UP_STATUSES),
picId: optionalString,
nextFollowUpAt: optionalString,
channel: z.enum(LEAD_SOURCES).optional().or(z.literal("")),
note: optionalString,
});
export type FollowUpInput = z.infer<typeof followUpSchema>;
export const attendanceBulkSchema = z.object({
sessionId: z.string().min(1),
entries: z.array(z.object({ studentId: z.string(), status: z.enum(ATTENDANCE_STATUSES), note: optionalString })),
});
export type AttendanceBulkInput = z.infer<typeof attendanceBulkSchema>;
export const journalSchema = z.object({
classId: z.string().min(1, "Pilih kelas"),
sessionId: optionalString,
title: z.string().trim().min(3, "Judul minimal 3 karakter"),
description: optionalString,
activityDate: optionalString,
tags: optionalString,
photoUrls: toIdList,
studentIds: toIdList,
});
export type JournalInput = z.infer<typeof journalSchema>;
export const reportCardSchema = z.object({
studentId: z.string().min(1, "Pilih siswa"),
classId: z.string().min(1, "Pilih kelas"),
period: z.string().trim().min(3, "Periode wajib diisi"),
teacherNotes: optionalString,
attendanceSummary: optionalString,
scores: z.record(z.string(), z.coerce.number().min(0).max(100)),
});
export type ReportCardInput = z.infer<typeof reportCardSchema>;
export const placementSchema = z.object({
studentId: z.string().min(1, "Pilih siswa"),
testDate: optionalString,
examinerId: optionalString,
recommendedClassId: optionalString,
notes: optionalString,
scores: z.record(z.string(), z.coerce.number().min(0).max(100)),
});
export type PlacementInput = z.infer<typeof placementSchema>;
export const loginSchema = z.object({
email: z.string().email("Email tidak valid"),
password: z.string().min(1, "Password wajib diisi"),
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "prisma/seed.example.ts"]
}