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:
@@ -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
@@ -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
|
||||||
@@ -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 (0–100) → `levelResult` (`BEGINNER`/`INTERMEDIATE`/`ADVANCED`) + rekomendasi kelas.
|
||||||
|
- Hasil **selalu tampil** di tab "Placement Test" pada halaman detail siswa dan sebagai badge di header profil.
|
||||||
|
|
||||||
|
### 7.6 Kelas
|
||||||
|
- Admin bisa CRUD kelas: nama, kode unik, level, guru, jumlah pertemuan, harga paket, kapasitas, jadwal, warna.
|
||||||
|
- Siswa baru **wajib memilih kelas** (minimal 1) saat diubah menjadi `STUDENT`. Validasi kapasitas: tolak jika `enrollments.active >= capacity`.
|
||||||
|
- Generate `Session` otomatis sebanyak `totalMeetings` dengan interval jadwal saat kelas diaktifkan (boleh manual).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API Endpoints
|
||||||
|
|
||||||
|
| Method | Route | Fungsi | Role |
|
||||||
|
|--------|-------|--------|------|
|
||||||
|
| GET/POST | `/api/students` | list (filter status/source/q) & create | ADMIN, TEACHER |
|
||||||
|
| GET/PATCH/DELETE | `/api/students/[id]` | detail, update, archive | ADMIN, TEACHER |
|
||||||
|
| POST | `/api/students/[id]/enroll` | enroll ke kelas | ADMIN |
|
||||||
|
| GET/POST | `/api/follow-ups` | list (due) & create | ADMIN, TEACHER |
|
||||||
|
| PATCH | `/api/follow-ups/[id]` | ubah status / jadwal / PIC | ADMIN, TEACHER |
|
||||||
|
| POST | `/api/follow-ups/[id]/activities` | catat aktivitas follow up | ADMIN, TEACHER |
|
||||||
|
| GET/POST | `/api/classes` | list & create kelas | ADMIN |
|
||||||
|
| GET/PATCH/DELETE | `/api/classes/[id]` | detail/update/archive | ADMIN |
|
||||||
|
| POST | `/api/classes/[id]/sessions` | generate/atur pertemuan | ADMIN, TEACHER |
|
||||||
|
| GET/POST | `/api/attendance` | per session & bulk upsert | ADMIN, TEACHER |
|
||||||
|
| GET/POST | `/api/payments` | list & buat pembayaran (prorata) | ADMIN |
|
||||||
|
| PATCH | `/api/payments/[id]` | update status / refund | ADMIN |
|
||||||
|
| GET/POST | `/api/journals` | feed & create (multipart foto) | ADMIN, TEACHER |
|
||||||
|
| GET/PATCH/DELETE | `/api/journals/[id]` | detail/update/hapus | ADMIN, TEACHER |
|
||||||
|
| GET/POST | `/api/report-cards` | list & create | ADMIN, TEACHER |
|
||||||
|
| PATCH | `/api/report-cards/[id]/publish` | publish ke siswa | ADMIN, TEACHER |
|
||||||
|
| GET/POST | `/api/placements` | list & create hasil placement | ADMIN, TEACHER |
|
||||||
|
| GET | `/api/dashboard/summary` | angka + data grafik right rail | ADMIN, TEACHER |
|
||||||
|
| POST | `/api/uploads` | upload file → URL | ADMIN, TEACHER |
|
||||||
|
|
||||||
|
Semua route pakai `withAuth(handler, { roles })` dari `src/lib/rbac.ts`. Validasi body dengan Zod. Response sukses `{ data }`, error `{ error: { code, message } }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. RBAC
|
||||||
|
|
||||||
|
| Area | ADMIN | TEACHER |
|
||||||
|
|------|:-----:|:-------:|
|
||||||
|
| Dashboard & right rail | ✅ | ✅ |
|
||||||
|
| CRUD siswa | ✅ | ✅ (tanpa archive) |
|
||||||
|
| CRUD follow up | ✅ | ✅ |
|
||||||
|
| CRUD kelas & setting | ✅ | 👁 baca saja |
|
||||||
|
| Input kehadiran | ✅ | ✅ (kelas sendiri) |
|
||||||
|
| Input jurnal & foto | ✅ | ✅ (kelas sendiri) |
|
||||||
|
| Input raport | ✅ | ✅ (kelas sendiri) |
|
||||||
|
| Input placement test | ✅ | ✅ |
|
||||||
|
| Pembayaran | ✅ | 👁 baca saja |
|
||||||
|
| Manajemen user | ✅ | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://user:pass@localhost:5432/kodeva"
|
||||||
|
AUTH_SECRET="ganti-dengan-random-32-char"
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
|
NEXT_PUBLIC_APP_NAME="Kodeva"
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
UPLOAD_DIR="./public/uploads"
|
||||||
|
MAX_UPLOAD_MB=5
|
||||||
|
TZ="Asia/Jakarta"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jangan pernah commit `.env`. Sediakan `.env.example`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Contoh Data
|
||||||
|
|
||||||
|
Dataset contoh lengkap ada di [`seed.example.ts`](./seed.example.ts): 3 user (1 admin, 2 teacher), 5 kelas, 10 siswa (campuran `CALON_STUDENT` / `STUDENT` / `EX_STUDENT`), enrollment, sesi, kehadiran, pembayaran prorata, jurnal + foto, raport, placement test, dan follow up.
|
||||||
|
|
||||||
|
Ringkasan untuk konteks cepat:
|
||||||
|
|
||||||
|
**Kelas (5):**
|
||||||
|
| Kode | Nama | Level | Pertemuan | Harga Paket | Rp/Pertemuan |
|
||||||
|
|------|------|-------|-----------|-------------|--------------|
|
||||||
|
| KOD-SCR-01 | Scratch Junior | Beginner | 8 | Rp 1.200.000 | Rp 150.000 |
|
||||||
|
| KOD-PYT-01 | Python Dasar | Beginner | 10 | Rp 2.000.000 | Rp 200.000 |
|
||||||
|
| KOD-WEB-01 | Web Dev: HTML & CSS | Beginner | 12 | Rp 2.400.000 | Rp 200.000 |
|
||||||
|
| KOD-JS-01 | JavaScript Intermediate | Intermediate | 10 | Rp 2.500.000 | Rp 250.000 |
|
||||||
|
| KOD-ARD-01 | Arduino & Robotics | Intermediate | 8 | Rp 1.800.000 | Rp 225.000 |
|
||||||
|
|
||||||
|
**Siswa (10):** Andi Pratama (STUDENT), Citra Lestari (CALON_STUDENT), Dimas Anggara (STUDENT), Elsa Maharani (CALON_STUDENT), Fajar Nugroho (STUDENT), Gita Permata (EX_STUDENT), Hadi Susanto (STUDENT), Intan Puspita (CALON_STUDENT), Joko Wijaya (STUDENT), Kirana Dewi (EX_STUDENT).
|
||||||
|
|
||||||
|
**Contoh prorata:** Andi bayar Rp 2.000.000 untuk 10 pertemuan Python Dasar → Rp 200.000/pertemuan. Hadir 6 → terpakai Rp 1.200.000, sisa 4 pertemuan (Rp 800.000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Testing & Quality
|
||||||
|
|
||||||
|
- **Unit wajib** untuk `prorata.ts`, `follow-up.ts`, `utils` format uang/tanggal, dan semua Zod schema.
|
||||||
|
- **Integration** untuk route handler (happy path + validasi gagal + unauthorized).
|
||||||
|
- **E2E** minimal: login → tambah calon siswa → follow up → enroll + bayar prorata → isi kehadiran → isi jurnal → isi raport → lihat placement test di detail siswa.
|
||||||
|
- Sertakan test case batas: `totalMeetings = 0`, `meetingsPaid > totalMeetings`, pembayaran parsial, kehadiran melebihi kuota.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Do / Don't untuk Agent
|
||||||
|
|
||||||
|
**DO**
|
||||||
|
- Baca `DESIGN.md` sebelum membuat/mengubah UI. Pakai token warna & komponen yang sudah ada, jangan hardcode hex baru.
|
||||||
|
- Jalankan `prisma generate` setelah mengubah schema.
|
||||||
|
- Buat komponen kecil & reusable; jangan file 800 baris.
|
||||||
|
- Tangani 4 state: **loading (skeleton), empty, error, success**.
|
||||||
|
- Aksesibel: label form, `aria-*`, fokus terlihat, kontras AA.
|
||||||
|
- Format uang & tanggal lewat util, jangan inline.
|
||||||
|
|
||||||
|
**DON'T**
|
||||||
|
- Jangan tambahkan payment gateway / integrasi pihak ketiga pembayaran.
|
||||||
|
- Jangan pakai `Float` untuk uang.
|
||||||
|
- Jangan fetch internal API dari Server Component.
|
||||||
|
- Jangan hapus data keras; gunakan archive.
|
||||||
|
- Jangan ubah `DESIGN.md`/palet tanpa persetujuan user.
|
||||||
|
- Jangan taruh logika prorata di komponen; selalu di `lib/prorata.ts` + test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Roadmap
|
||||||
|
|
||||||
|
- [ ] **M1** Auth + shell layout (left nav, right rail) + Prisma schema + seed
|
||||||
|
- [ ] **M2** CRUD Siswa + Follow Up kanban + status
|
||||||
|
- [ ] **M3** CRUD Kelas + Enrollment + Sesi/Pertemuan
|
||||||
|
- [ ] **M4** Kehadiran + Pembayaran prorata
|
||||||
|
- [ ] **M5** Jurnal + upload foto
|
||||||
|
- [ ] **M6** Raport + Placement Test
|
||||||
|
- [ ] **M7** Dashboard right rail (semua grafik) + polish
|
||||||
|
- [ ] **M8** Export, notifikasi WhatsApp manual, audit log
|
||||||
@@ -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 | 30–36px, tracking `-0.02em` |
|
||||||
|
| H2 | Plus Jakarta Sans | 700 | 24px |
|
||||||
|
| H3 / Card title | Plus Jakarta Sans | 600 | 18px |
|
||||||
|
| Body | **Inter** | 400/500 | 14–15px, line-height 1.6 |
|
||||||
|
| Caption / label | Inter | 500 | 12–13px, tracking `0.01em` |
|
||||||
|
| Angka dashboard | **Sora** | 700 | 28–32px, 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 6–12%**, gradient brand. Maksimal 1–2 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 |
|
||||||
|
| `1280–1439px` | 3 kolom, rail 300px |
|
||||||
|
| `1024–1279px` | rail jadi drawer kanan (default tutup) |
|
||||||
|
| `768–1023px` | 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 20–24px.
|
||||||
|
- **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 180–220px 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 0–100): 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`.
|
||||||
@@ -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 **8–10 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 | 3–4 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 | M2–M6 | #2, #7 |
|
||||||
|
| M8 | Hardening, QA & Polish | 1 minggu | M0–M7 | — |
|
||||||
|
| M9 | UAT & Launch | 1 minggu | M8 | semua |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Detail Per Milestone
|
||||||
|
|
||||||
|
### M0 — Setup & Scaffolding (3–4 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) 0–100 → 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 | ⬜ |
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Generated
+5291
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@prisma/client': true
|
||||||
|
'@prisma/engines': true
|
||||||
|
esbuild: true
|
||||||
|
prisma: true
|
||||||
|
sharp: false
|
||||||
|
unrs-resolver: false
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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(); });
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}` } });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 0–100</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: <65 Beginner · 65–84 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 0–100</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user