feat: implementasi Kodeva - sistem manajemen kursus coding

Fitur lengkap:
- Auth JWT + RBAC (Admin/Teacher) + halaman login
- Shell 3 kolom (left nav, topbar, right rail dashboard)
- CRUD siswa + follow up kanban + status calon/student/ex
- Dashboard: siswa aktif, kehadiran, bar sumber lead, perlu follow up
- Pembayaran prorata per pertemuan (cash/transfer/qris)
- Kelas, enrollment, generate sesi, kehadiran bulk
- Jurnal aktivitas + upload multi-foto
- Raport + placement test (tampil di detail siswa)
- Pengaturan & seed data contoh

Stack: Next.js 16, TypeScript, Tailwind v4, Prisma (SQLite), Recharts, Zod
This commit is contained in:
Amadea
2026-09-18 19:24:59 +07:00
commit 61ce9d7926
86 changed files with 13392 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
// Kodeva — Prisma schema
// DEV: SQLite (tanpa Docker/Postgres). Untuk PRODUKSI ganti provider ke "postgresql"
// dan ubah String enum/list kembali menjadi enum + String[] (lihat AGENTS.md §6).
//
// Nilai enum disimpan sebagai String agar kompatibel SQLite. Konstanta TypeScript
// tersedia di src/lib/constants.ts.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
role String @default("TEACHER") // ADMIN | TEACHER
avatarUrl String?
phone String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
classesTaught Class[] @relation("ClassTeacher")
sessions Session[]
journals Journal[]
reportCards ReportCard[]
placementTests PlacementTest[] @relation("Examiner")
followUpsOwned FollowUp[] @relation("FollowUpPIC")
}
model Student {
id String @id @default(cuid())
fullName String
phone String
email String? @unique
photoUrl String?
parentName String?
parentPhone String?
birthDate DateTime?
gender String? // MALE | FEMALE
address String?
leadSource String // WHATSAPP | REFERRAL | BANNER | SOCIAL_MEDIA
status String @default("CALON_STUDENT") // CALON_STUDENT | STUDENT | EX_STUDENT
notes String?
joinedAt DateTime?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enrollments Enrollment[]
attendances Attendance[]
payments Payment[]
reportCards ReportCard[]
placementTests PlacementTest[]
followUps FollowUp[]
journalStudents JournalStudent[]
@@index([status])
@@index([leadSource])
}
model Class {
id String @id @default(cuid())
name String
code String @unique
description String?
level String // BEGINNER | INTERMEDIATE | ADVANCED
teacherId String?
teacher User? @relation("ClassTeacher", fields: [teacherId], references: [id])
totalMeetings Int @default(10)
packagePrice Int @default(0)
pricePerMeeting Int?
capacity Int @default(12)
schedule String?
startDate DateTime?
endDate DateTime?
status String @default("ACTIVE") // DRAFT | ACTIVE | COMPLETED | ARCHIVED
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enrollments Enrollment[]
sessions Session[]
payments Payment[]
journals Journal[]
reportCards ReportCard[]
}
model Enrollment {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
status String @default("ACTIVE") // ACTIVE | COMPLETED | DROPPED
joinedAt DateTime @default(now())
leftAt DateTime?
createdAt DateTime @default(now())
@@unique([studentId, classId])
}
model Session {
id String @id @default(cuid())
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
meetingNumber Int
date DateTime
topic String?
teacherId String?
teacher User? @relation(fields: [teacherId], references: [id])
createdAt DateTime @default(now())
attendances Attendance[]
journals Journal[]
@@unique([classId, meetingNumber])
}
model Attendance {
id String @id @default(cuid())
sessionId String
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
status String // PRESENT | LATE | EXCUSED | SICK | ABSENT
note String?
recordedById String?
createdAt DateTime @default(now())
@@unique([sessionId, studentId])
}
model Payment {
id String @id @default(cuid())
invoiceNumber String @unique
studentId String
student Student @relation(fields: [studentId], references: [id])
classId String
class Class @relation(fields: [classId], references: [id])
meetingsPaid Int
pricePerMeeting Int
amount Int
method String // CASH | TRANSFER | QRIS
status String @default("PAID") // UNPAID | PARTIAL | PAID | REFUNDED
paidAt DateTime
note String?
proofUrl String?
createdById String?
createdAt DateTime @default(now())
}
model Journal {
id String @id @default(cuid())
classId String
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
sessionId String?
session Session? @relation(fields: [sessionId], references: [id])
title String
description String?
activityDate DateTime
tags String @default("") // comma separated
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
photos JournalPhoto[]
students JournalStudent[]
}
model JournalPhoto {
id String @id @default(cuid())
journalId String
journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade)
url String
caption String?
sortOrder Int @default(0)
}
model JournalStudent {
journalId String
studentId String
journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@id([journalId, studentId])
}
model ReportCard {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
classId String
class Class @relation(fields: [classId], references: [id])
period String
teacherId String
teacher User @relation(fields: [teacherId], references: [id])
attendanceSummary String?
finalScore Int?
grade String?
teacherNotes String?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scores ReportScore[]
}
model ReportScore {
id String @id @default(cuid())
reportCardId String
reportCard ReportCard @relation(fields: [reportCardId], references: [id], onDelete: Cascade)
aspect String
score Int
note String?
}
model PlacementTest {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
testDate DateTime
levelResult String // BEGINNER | INTERMEDIATE | ADVANCED
recommendedClassId String?
totalScore Int
examinerId String?
examiner User? @relation("Examiner", fields: [examinerId], references: [id])
notes String?
createdAt DateTime @default(now())
scores PlacementScore[]
}
model PlacementScore {
id String @id @default(cuid())
placementTestId String
placementTest PlacementTest @relation(fields: [placementTestId], references: [id], onDelete: Cascade)
category String
score Int
}
model FollowUp {
id String @id @default(cuid())
studentId String
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
status String @default("NEW") // NEW | CONTACTED | TRIAL | OFFER_SENT | ENROLLED | LOST
picId String?
pic User? @relation("FollowUpPIC", fields: [picId], references: [id])
nextFollowUpAt DateTime?
lastContactedAt DateTime?
channel String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities FollowUpActivity[]
@@index([status])
@@index([nextFollowUpAt])
}
model FollowUpActivity {
id String @id @default(cuid())
followUpId String
followUp FollowUp @relation(fields: [followUpId], references: [id], onDelete: Cascade)
type String // call | wa | email | meeting
note String
authorId String?
createdAt DateTime @default(now())
}
model Setting {
key String @id
value String // JSON string (SQLite)
}
+329
View File
@@ -0,0 +1,329 @@
/**
* seed.example.ts — Contoh data Kodeva (Sistem Manajemen Kursus Coding)
* ---------------------------------------------------------------------
* Cara pakai:
* 1) Salin ke `prisma/seed.ts`
* 2) Tambahkan ke package.json → "prisma": { "seed": "tsx prisma/seed.ts" }
* 3) Jalankan: pnpm db:push && pnpm db:seed
*
* Login contoh:
* admin@kodeva.id / password123 (ADMIN)
* budi@kodeva.id / password123 (TEACHER)
* rina@kodeva.id / password123 (TEACHER)
*
* Catatan: cocok dengan skema di AGENTS.md (PostgreSQL).
*/
import { PrismaClient, AttendanceStatus, PaymentStatus, PlacementLevel } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const BASE = new Date("2026-09-18T00:00:00.000Z");
const addDays = (n: number, from: Date = BASE) => new Date(from.getTime() + n * 86_400_000);
const addWeeks = (n: number, from: Date = BASE) => addDays(n * 7, from);
const PASS = bcrypt.hashSync("password123", 10);
const avatar = (seed: string) => `https://i.pravatar.cc/240?u=${encodeURIComponent(seed)}`;
const photo = (seed: string) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/900/600`;
async function main() {
console.log("🌱 Seed Kodeva dimulai...");
// Bersihkan (urutan penting karena relasi)
await prisma.followUpActivity.deleteMany();
await prisma.followUp.deleteMany();
await prisma.placementScore.deleteMany();
await prisma.placementTest.deleteMany();
await prisma.reportScore.deleteMany();
await prisma.reportCard.deleteMany();
await prisma.journalStudent.deleteMany();
await prisma.journalPhoto.deleteMany();
await prisma.journal.deleteMany();
await prisma.payment.deleteMany();
await prisma.attendance.deleteMany();
await prisma.session.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.student.deleteMany();
await prisma.class.deleteMany();
await prisma.user.deleteMany();
await prisma.setting.deleteMany();
// ─────────────────────────────────────────────────────────────
// 1. USERS (Admin + Teacher)
// ─────────────────────────────────────────────────────────────
const sari = await prisma.user.create({
data: {
name: "Sari Wulandari", email: "admin@kodeva.id", passwordHash: PASS,
role: "ADMIN", phone: "0811-2000-100", avatarUrl: avatar("sari"),
},
});
const budi = await prisma.user.create({
data: {
name: "Budi Santoso", email: "budi@kodeva.id", passwordHash: PASS,
role: "TEACHER", phone: "0812-3000-200", avatarUrl: avatar("budi"),
},
});
const rina = await prisma.user.create({
data: {
name: "Rina Kartika", email: "rina@kodeva.id", passwordHash: PASS,
role: "TEACHER", phone: "0813-4000-300", avatarUrl: avatar("rina"),
},
});
const ahmad = await prisma.user.create({
data: {
name: "Ahmad Fauzi", email: "ahmad@kodeva.id", passwordHash: PASS,
role: "TEACHER", phone: "0814-5000-400", avatarUrl: avatar("ahmad"),
},
});
// ─────────────────────────────────────────────────────────────
// 2. KELAS (konfigurasi kelas)
// pricePerMeeting = packagePrice / totalMeetings
// ─────────────────────────────────────────────────────────────
const classesData = [
{ code: "KOD-SCR-01", name: "Scratch Junior", level: "BEGINNER" as PlacementLevel, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_200_000, pricePerMeeting: 150_000, capacity: 10, schedule: "Senin & Rabu 16:00-17:30", color: "#2AA9FF", description: "Belajar logika pemrograman lewat game visual Scratch." },
{ code: "KOD-PYT-01", name: "Python Dasar", level: "BEGINNER" as PlacementLevel, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_000_000, pricePerMeeting: 200_000, capacity: 12, schedule: "Selasa & Kamis 17:00-18:30", color: "#7C5CFC", description: "Fondasi Python: variabel, logika, loop, dan fungsi." },
{ code: "KOD-WEB-01", name: "Web Dev: HTML & CSS", level: "BEGINNER" as PlacementLevel, teacherId: ahmad.id, totalMeetings: 12, packagePrice: 2_400_000, pricePerMeeting: 200_000, capacity: 14, schedule: "Sabtu 09:00-12:00", color: "#12B886", description: "Membangun website pertamamu dari nol." },
{ code: "KOD-JS-01", name: "JavaScript Intermediate", level: "INTERMEDIATE" as PlacementLevel, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_500_000, pricePerMeeting: 250_000, capacity: 12, schedule: "Selasa & Kamis 18:45-20:15", color: "#FFB020", description: "DOM, event, async, dan mini project interaktif." },
{ code: "KOD-ARD-01", name: "Arduino & Robotics", level: "INTERMEDIATE" as PlacementLevel, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_800_000, pricePerMeeting: 225_000, capacity: 8, schedule: "Jumat 15:30-17:30", color: "#F65FA7", description: "Rakit dan program robot pertamamu dengan Arduino." },
];
const klass = Object.fromEntries(
await Promise.all(
classesData.map(async (c) => [c.code, await prisma.class.create({ data: { ...c, startDate: addWeeks(-10), status: "ACTIVE" } })]),
),
) as Record<string, Awaited<ReturnType<typeof prisma.class.create>>>;
// ─────────────────────────────────────────────────────────────
// 3. SISWA (10 orang: 4 calon, 4 student, 2 ex student)
// Field: nama, telepon, email, foto, orang tua, sumber lead, status
// ─────────────────────────────────────────────────────────────
const studentsSeed = [
{ key: "andi", fullName: "Andi Pratama", phone: "0812-1111-0001", email: "andi@example.com", parentName: "Budi Pratama", parentPhone: "0812-1111-9001", leadSource: "REFERRAL" as const, status: "STUDENT" as const, joinedAt: addWeeks(-10) },
{ key: "citra", fullName: "Citra Lestari", phone: "0812-1111-0002", email: "citra@example.com", parentName: "Sari Lestari", parentPhone: "0812-1111-9002", leadSource: "WHATSAPP" as const, status: "CALON_STUDENT" as const, joinedAt: null },
{ key: "dimas", fullName: "Dimas Anggara", phone: "0812-1111-0003", email: "dimas@example.com", parentName: "Hendra Anggara", parentPhone: "0812-1111-9003", leadSource: "BANNER" as const, status: "STUDENT" as const, joinedAt: addWeeks(-9) },
{ key: "elsa", fullName: "Elsa Maharani", phone: "0812-1111-0004", email: "elsa@example.com", parentName: "Rina Maharani", parentPhone: "0812-1111-9004", leadSource: "SOCIAL_MEDIA" as const, status: "CALON_STUDENT" as const, joinedAt: null },
{ key: "fajar", fullName: "Fajar Nugroho", phone: "0812-1111-0005", email: "fajar@example.com", parentName: "Agus Nugroho", parentPhone: "0812-1111-9005", leadSource: "WHATSAPP" as const, status: "STUDENT" as const, joinedAt: addWeeks(-8) },
{ key: "gita", fullName: "Gita Permata", phone: "0812-1111-0006", email: "gita@example.com", parentName: "Dwi Permata", parentPhone: "0812-1111-9006", leadSource: "REFERRAL" as const, status: "EX_STUDENT" as const, joinedAt: addWeeks(-40) },
{ key: "hadi", fullName: "Hadi Susanto", phone: "0812-1111-0007", email: "hadi@example.com", parentName: "Tono Susanto", parentPhone: "0812-1111-9007", leadSource: "SOCIAL_MEDIA" as const, status: "STUDENT" as const, joinedAt: addWeeks(-6) },
{ key: "intan", fullName: "Intan Puspita", phone: "0812-1111-0008", email: "intan@example.com", parentName: "Maya Puspita", parentPhone: "0812-1111-9008", leadSource: "BANNER" as const, status: "CALON_STUDENT" as const, joinedAt: null },
{ key: "joko", fullName: "Joko Wijaya", phone: "0812-1111-0009", email: "joko@example.com", parentName: "Slamet Wijaya", parentPhone: "0812-1111-9009", leadSource: "WHATSAPP" as const, status: "STUDENT" as const, joinedAt: addWeeks(-4) },
{ key: "kirana", fullName: "Kirana Dewi", phone: "0812-1111-0010", email: "kirana@example.com", parentName: "Ratna Dewi", parentPhone: "0812-1111-9010", leadSource: "REFERRAL" as const, status: "EX_STUDENT" as const, joinedAt: addWeeks(-36) },
];
const student = {} as Record<string, Awaited<ReturnType<typeof prisma.student.create>>>;
for (const s of studentsSeed) {
student[s.key] = await prisma.student.create({
data: {
fullName: s.fullName, phone: s.phone, email: s.email,
photoUrl: avatar(s.key), parentName: s.parentName, parentPhone: s.parentPhone,
leadSource: s.leadSource, status: s.status, joinedAt: s.joinedAt,
birthDate: new Date("2012-05-10T00:00:00.000Z"),
gender: "MALE", address: "Jl. Merdeka No. 10, Bandung",
},
});
}
// ─────────────────────────────────────────────────────────────
// 4. ENROLLMENT ( siswa ↔ kelas )
// ─────────────────────────────────────────────────────────────
const enrollments = [
{ key: "andi", code: "KOD-PYT-01", status: "ACTIVE" as const },
{ key: "dimas", code: "KOD-SCR-01", status: "ACTIVE" as const },
{ key: "fajar", code: "KOD-WEB-01", status: "ACTIVE" as const },
{ key: "gita", code: "KOD-JS-01", status: "COMPLETED" as const },
{ key: "hadi", code: "KOD-ARD-01", status: "ACTIVE" as const },
{ key: "joko", code: "KOD-PYT-01", status: "ACTIVE" as const },
{ key: "kirana", code: "KOD-SCR-01", status: "COMPLETED" as const },
];
for (const e of enrollments) {
await prisma.enrollment.create({
data: { studentId: student[e.key].id, classId: klass[e.code].id, status: e.status, joinedAt: student[e.key].joinedAt ?? BASE, leftAt: e.status === "COMPLETED" ? addWeeks(-2) : null },
});
}
// ─────────────────────────────────────────────────────────────
// 5. SESSION (pertemuan per kelas) — dasar prorata
// ─────────────────────────────────────────────────────────────
const session: Record<string, { id: string; meetingNumber: number }[]> = {};
for (const c of Object.values(klass)) {
session[c.code] = [];
for (let i = 1; i <= c.totalMeetings; i++) {
const s = await prisma.session.create({
data: { classId: c.id, meetingNumber: i, date: addWeeks(-10 + i - 1), topic: `Pertemuan ${i}: ${c.name}`, teacherId: c.teacherId },
});
session[c.code].push({ id: s.id, meetingNumber: i });
}
}
// ─────────────────────────────────────────────────────────────
// 6. KEHADIRAN (PRESENT/LATE/EXCUSED/SICK/ABSENT)
// Terpakai = jumlah PRESENT + LATE → dasar hitung sisa kuota
// ─────────────────────────────────────────────────────────────
const plan: Record<string, { code: string; statuses: AttendanceStatus[] }> = {
andi: { code: "KOD-PYT-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] }, // 6 terpakai
dimas: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "ABSENT", "PRESENT"] }, // 3 hadir, 1 alpa
fajar: { code: "KOD-WEB-01", statuses: ["PRESENT", "LATE", "PRESENT", "PRESENT", "EXCUSED", "PRESENT"] }, // 5 hadir
gita: { code: "KOD-JS-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
hadi: { code: "KOD-ARD-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
joko: { code: "KOD-PYT-01", statuses: ["PRESENT", "SICK", "PRESENT", "PRESENT", "PRESENT"] },
kirana: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] },
};
let attendanceCount = 0;
for (const [key, p] of Object.entries(plan)) {
const sessions = session[p.code];
for (let i = 0; i < p.statuses.length; i++) {
await prisma.attendance.create({
data: { sessionId: sessions[i].id, studentId: student[key].id, status: p.statuses[i], recordedById: klass[p.code].teacherId, note: p.statuses[i] === "SICK" ? "Demam" : undefined },
});
attendanceCount++;
}
}
// ─────────────────────────────────────────────────────────────
// 7. PEMBAYARAN PRORATA
// amount = meetingsPaid × pricePerMeeting
// Contoh: Andi 10 × 200.000 = 2.000.000
// Hadir 6 → terpakai 1.200.000 → sisa 4 pertemuan = 800.000
// ─────────────────────────────────────────────────────────────
const paymentsSeed = [
{ key: "andi", code: "KOD-PYT-01", meetingsPaid: 10, method: "TRANSFER" as const, status: "PAID" as const, paidAt: addWeeks(-10) },
{ key: "dimas", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH" as const, status: "PAID" as const, paidAt: addWeeks(-9) },
{ key: "fajar", code: "KOD-WEB-01", meetingsPaid: 6, method: "QRIS" as const, status: "PAID" as const, paidAt: addWeeks(-8), note: "Bayar 6 pertemuan dulu" },
{ key: "gita", code: "KOD-JS-01", meetingsPaid: 10, method: "TRANSFER" as const, status: "PAID" as const, paidAt: addWeeks(-40) },
{ key: "hadi", code: "KOD-ARD-01", meetingsPaid: 8, method: "TRANSFER" as const, status: "PAID" as const, paidAt: addWeeks(-6) },
{ key: "joko", code: "KOD-PYT-01", meetingsPaid: 5, method: "QRIS" as const, status: "PARTIAL" as const, paidAt: addWeeks(-4), note: "Cicilan 1 dari 2" },
{ key: "kirana", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH" as const, status: "PAID" as const, paidAt: addWeeks(-36) },
];
let paySeq = 1;
for (const p of paymentsSeed) {
const c = klass[p.code];
const ppm = c.pricePerMeeting ?? Math.round(c.packagePrice / c.totalMeetings);
await prisma.payment.create({
data: {
invoiceNumber: `INV-2026-${String(paySeq++).padStart(4, "0")}`,
studentId: student[p.key].id, classId: c.id,
meetingsPaid: p.meetingsPaid, pricePerMeeting: ppm, amount: p.meetingsPaid * ppm,
method: p.method, status: p.status as PaymentStatus, paidAt: p.paidAt, note: p.note, createdById: sari.id,
},
});
}
// ─────────────────────────────────────────────────────────────
// 8. JURNAL AKTIVITAS + FOTO
// ─────────────────────────────────────────────────────────────
const journalsSeed = [
{ code: "KOD-SCR-01", title: "Membuat Game Ular dengan Scratch", description: "Anak-anak belajar event, loop, dan variabel skor sambil membuat game ular versi mereka sendiri.", tags: ["game", "scratch", "logika"], date: addWeeks(-2), students: ["dimas", "kirana"], photos: ["ular-1", "ular-2", "ular-3"] },
{ code: "KOD-PYT-01", title: "Dasar Variabel & Tipe Data Python", description: "Sesi seru bereksperimen dengan string, integer, dan input dari pengguna.", tags: ["python", "variable", "basic"], date: addWeeks(-3), students: ["andi", "joko"], photos: ["python-1", "python-2"] },
{ code: "KOD-WEB-01", title: "Landing Page Pertama", description: "Setiap siswa mempublikasikan halaman profil pribadinya menggunakan HTML & CSS.", tags: ["html", "css", "project"], date: addWeeks(-1), students: ["fajar"], photos: ["web-1", "web-2", "web-3", "web-4"] },
{ code: "KOD-ARD-01", title: "Sensor Cahaya dengan Arduino", description: "Merakit lampu otomatis yang menyala saat ruangan gelap.", tags: ["arduino", "sensor", "robotik"], date: addDays(-5), students: ["hadi"], photos: ["arduino-1", "arduino-2"] },
];
for (const j of journalsSeed) {
const c = klass[j.code];
await prisma.journal.create({
data: {
classId: c.id, sessionId: session[j.code][0].id, title: j.title, description: j.description,
activityDate: j.date, tags: j.tags, authorId: c.teacherId!,
photos: { create: j.photos.map((s, i) => ({ url: photo(s), caption: `Dokumentasi ${i + 1}`, sortOrder: i })) },
students: { create: j.students.map((k) => ({ studentId: student[k].id })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 9. RAPORT
// ─────────────────────────────────────────────────────────────
const reportsSeed = [
{ key: "andi", code: "KOD-PYT-01", period: "2026-Term1", scores: [["Logika", 88], ["Computational Thinking", 85], ["Problem Solving", 82], ["Kreativitas", 90], ["Kolaborasi", 86]], notes: "Andi sangat aktif bertanya dan cepat memahami konsep loop." },
{ key: "dimas", code: "KOD-SCR-01", period: "2026-Term1", scores: [["Logika", 80], ["Computational Thinking", 78], ["Problem Solving", 75], ["Kreativitas", 92], ["Kolaborasi", 88]], notes: "Kreativitas Dimas luar biasa, perlu latihan ketelitian." },
{ key: "fajar", code: "KOD-WEB-01", period: "2026-Term1", scores: [["Logika", 84], ["Computational Thinking", 82], ["Problem Solving", 86], ["Kreativitas", 85], ["Kolaborasi", 80]], notes: "Progres pesat di CSS layout." },
] as const;
for (const r of reportsSeed) {
const avg = Math.round(r.scores.reduce((a, [, v]) => a + v, 0) / r.scores.length);
const grade = avg >= 90 ? "A" : avg >= 80 ? "B" : avg >= 70 ? "C" : "D";
await prisma.reportCard.create({
data: {
studentId: student[r.key].id, classId: klass[r.code].id, period: r.period,
teacherId: klass[r.code].teacherId!, finalScore: avg, grade, teacherNotes: r.notes,
attendanceSummary: "Hadir 6 dari 6 pertemuan", publishedAt: addDays(-3),
scores: { create: r.scores.map(([aspect, score]) => ({ aspect, score })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 10. PLACEMENT TEST (hasil tes penempatan → tampil di detail siswa)
// ─────────────────────────────────────────────────────────────
const placementsSeed = [
{ key: "elsa", level: "INTERMEDIATE" as PlacementLevel, rec: "KOD-JS-01", scores: [["Logika", 82], ["Dasar Coding", 76], ["Problem Solving", 80], ["Kreativitas", 74]] },
{ key: "intan", level: "BEGINNER" as PlacementLevel, rec: "KOD-SCR-01", scores: [["Logika", 62], ["Dasar Coding", 48], ["Problem Solving", 55], ["Kreativitas", 70]] },
{ key: "citra", level: "BEGINNER" as PlacementLevel, rec: "KOD-PYT-01", scores: [["Logika", 50], ["Dasar Coding", 40], ["Problem Solving", 45], ["Kreativitas", 60]] },
{ key: "andi", level: "INTERMEDIATE" as PlacementLevel, rec: "KOD-JS-01", scores: [["Logika", 80], ["Dasar Coding", 78], ["Problem Solving", 72], ["Kreativitas", 68]] },
];
for (const p of placementsSeed) {
const total = Math.round(p.scores.reduce((a, [, v]) => a + v, 0) / p.scores.length);
await prisma.placementTest.create({
data: {
studentId: student[p.key].id, testDate: addDays(-12), levelResult: p.level,
recommendedClassId: klass[p.rec].id, totalScore: total, examinerId: rina.id,
notes: `Direkomendasikan masuk kelas ${klass[p.rec].name}.`,
scores: { create: p.scores.map(([category, score]) => ({ category, score })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 11. FOLLOW UP (berapa yang harus di-follow up)
// due = nextFollowUpAt <= hari ini && status ∉ (ENROLLED, LOST)
// ─────────────────────────────────────────────────────────────
const followUpsSeed = [
{ key: "citra", status: "CONTACTED" as const, picId: sari.id, next: addDays(-1), last: addDays(-3), channel: "WHATSAPP" as const, activities: [["wa", "Kirim brosur Python Dasar, ortu minta dihubungi lagi."]] },
{ key: "elsa", status: "NEW" as const, picId: budi.id, next: addDays(0), last: null, channel: "SOCIAL_MEDIA" as const, activities: [["wa", "Lead dari Instagram, tanya jadwal kelas."]] },
{ key: "intan", status: "TRIAL" as const, picId: rina.id, next: addDays(2), last: addDays(-1), channel: "BANNER" as const, activities: [["call", "Sudah trial Scratch, tinggal konfirmasi pembayaran."]] },
];
for (const f of followUpsSeed) {
await prisma.followUp.create({
data: {
studentId: student[f.key].id, status: f.status, picId: f.picId,
nextFollowUpAt: f.next, lastContactedAt: f.last, channel: f.channel,
activities: { create: f.activities.map(([type, note]) => ({ type, note, authorId: f.picId })) },
},
});
}
// ─────────────────────────────────────────────────────────────
// 12. SETTINGS
// ─────────────────────────────────────────────────────────────
await prisma.setting.createMany({
data: [
{ key: "academy", value: { name: "Kodeva Academy", address: "Jl. Coding Raya No. 1, Bandung", phone: "0821-1000-2000", email: "halo@kodeva.id" } },
{ key: "leadSources", value: [
{ value: "WHATSAPP", label: "WhatsApp", color: "#22C55E", active: true },
{ value: "REFERRAL", label: "Referral", color: "#7C5CFC", active: true },
{ value: "BANNER", label: "Banner", color: "#FFB020", active: true },
{ value: "SOCIAL_MEDIA", label: "Social Media", color: "#2AA9FF", active: true },
] },
{ key: "currency", value: { code: "IDR", locale: "id-ID", timezone: "Asia/Jakarta" } },
],
});
// ─────────────────────────────────────────────────────────────
// Ringkasan (untuk verifikasi dashboard)
// ─────────────────────────────────────────────────────────────
const [activeStudents, exStudents, followUpDue] = await Promise.all([
prisma.student.count({ where: { status: "STUDENT" } }),
prisma.student.count({ where: { status: "EX_STUDENT" } }),
prisma.followUp.count({ where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: addDays(1) } } }),
]);
console.log("✅ Seed selesai.");
console.log(` 👥 Siswa aktif : ${activeStudents}`);
console.log(` 🎓 Ex student : ${exStudents}`);
console.log(` 🔔 Perlu follow up: ${followUpDue}`);
console.log(` 📚 Kelas : ${Object.keys(klass).length}`);
console.log(` 🗓️ Kehadiran : ${attendanceCount} record`);
console.log("\n Login: admin@kodeva.id / password123");
}
main()
.catch((e) => { console.error("❌ Seed gagal:", e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+279
View File
@@ -0,0 +1,279 @@
/**
* seed.ts — Contoh data Kodeva (Sistem Manajemen Kursus Coding)
* Jalankan: pnpm db:seed
*
* Login contoh:
* admin@kodeva.id / password123 (ADMIN)
* budi@kodeva.id / password123 (TEACHER)
* rina@kodeva.id / password123 (TEACHER)
*/
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const BASE = new Date("2026-09-18T00:00:00.000Z");
const addDays = (n: number, from: Date = BASE) => new Date(from.getTime() + n * 86_400_000);
const addWeeks = (n: number, from: Date = BASE) => addDays(n * 7, from);
const PASS = bcrypt.hashSync("password123", 10);
const avatar = (seed: string) => `https://i.pravatar.cc/240?u=${encodeURIComponent(seed)}`;
const photo = (seed: string) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/900/600`;
type Att = "PRESENT" | "LATE" | "EXCUSED" | "SICK" | "ABSENT";
type Level = "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
async function main() {
console.log("🌱 Seed Kodeva dimulai...");
await prisma.followUpActivity.deleteMany();
await prisma.followUp.deleteMany();
await prisma.placementScore.deleteMany();
await prisma.placementTest.deleteMany();
await prisma.reportScore.deleteMany();
await prisma.reportCard.deleteMany();
await prisma.journalStudent.deleteMany();
await prisma.journalPhoto.deleteMany();
await prisma.journal.deleteMany();
await prisma.payment.deleteMany();
await prisma.attendance.deleteMany();
await prisma.session.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.student.deleteMany();
await prisma.class.deleteMany();
await prisma.user.deleteMany();
await prisma.setting.deleteMany();
// 1. USERS
const sari = await prisma.user.create({
data: { name: "Sari Wulandari", email: "admin@kodeva.id", passwordHash: PASS, role: "ADMIN", phone: "0811-2000-100", avatarUrl: avatar("sari") },
});
const budi = await prisma.user.create({
data: { name: "Budi Santoso", email: "budi@kodeva.id", passwordHash: PASS, role: "TEACHER", phone: "0812-3000-200", avatarUrl: avatar("budi") },
});
const rina = await prisma.user.create({
data: { name: "Rina Kartika", email: "rina@kodeva.id", passwordHash: PASS, role: "TEACHER", phone: "0813-4000-300", avatarUrl: avatar("rina") },
});
const ahmad = await prisma.user.create({
data: { name: "Ahmad Fauzi", email: "ahmad@kodeva.id", passwordHash: PASS, role: "TEACHER", phone: "0814-5000-400", avatarUrl: avatar("ahmad") },
});
// 2. KELAS
const classesData = [
{ code: "KOD-SCR-01", name: "Scratch Junior", level: "BEGINNER" as Level, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_200_000, pricePerMeeting: 150_000, capacity: 10, schedule: "Senin & Rabu 16:00-17:30", color: "#2AA9FF", description: "Belajar logika pemrograman lewat game visual Scratch." },
{ code: "KOD-PYT-01", name: "Python Dasar", level: "BEGINNER" as Level, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_000_000, pricePerMeeting: 200_000, capacity: 12, schedule: "Selasa & Kamis 17:00-18:30", color: "#7C5CFC", description: "Fondasi Python: variabel, logika, loop, dan fungsi." },
{ code: "KOD-WEB-01", name: "Web Dev: HTML & CSS", level: "BEGINNER" as Level, teacherId: ahmad.id, totalMeetings: 12, packagePrice: 2_400_000, pricePerMeeting: 200_000, capacity: 14, schedule: "Sabtu 09:00-12:00", color: "#12B886", description: "Membangun website pertamamu dari nol." },
{ code: "KOD-JS-01", name: "JavaScript Intermediate", level: "INTERMEDIATE" as Level, teacherId: budi.id, totalMeetings: 10, packagePrice: 2_500_000, pricePerMeeting: 250_000, capacity: 12, schedule: "Selasa & Kamis 18:45-20:15", color: "#FFB020", description: "DOM, event, async, dan mini project interaktif." },
{ code: "KOD-ARD-01", name: "Arduino & Robotics", level: "INTERMEDIATE" as Level, teacherId: rina.id, totalMeetings: 8, packagePrice: 1_800_000, pricePerMeeting: 225_000, capacity: 8, schedule: "Jumat 15:30-17:30", color: "#F65FA7", description: "Rakit dan program robot pertamamu dengan Arduino." },
];
const klass: Record<string, { id: string; totalMeetings: number; pricePerMeeting: number | null; packagePrice: number; teacherId: string | null; name: string }> = {};
for (const c of classesData) {
const created = await prisma.class.create({ data: { ...c, startDate: addWeeks(-10), status: "ACTIVE" } });
klass[c.code] = created;
}
// 3. SISWA
const studentsSeed = [
{ key: "andi", fullName: "Andi Pratama", phone: "0812-1111-0001", email: "andi@example.com", parentName: "Budi Pratama", parentPhone: "0812-1111-9001", leadSource: "REFERRAL", status: "STUDENT", joinedAt: addWeeks(-10) },
{ key: "citra", fullName: "Citra Lestari", phone: "0812-1111-0002", email: "citra@example.com", parentName: "Sari Lestari", parentPhone: "0812-1111-9002", leadSource: "WHATSAPP", status: "CALON_STUDENT", joinedAt: null as Date | null },
{ key: "dimas", fullName: "Dimas Anggara", phone: "0812-1111-0003", email: "dimas@example.com", parentName: "Hendra Anggara", parentPhone: "0812-1111-9003", leadSource: "BANNER", status: "STUDENT", joinedAt: addWeeks(-9) },
{ key: "elsa", fullName: "Elsa Maharani", phone: "0812-1111-0004", email: "elsa@example.com", parentName: "Rina Maharani", parentPhone: "0812-1111-9004", leadSource: "SOCIAL_MEDIA", status: "CALON_STUDENT", joinedAt: null as Date | null },
{ key: "fajar", fullName: "Fajar Nugroho", phone: "0812-1111-0005", email: "fajar@example.com", parentName: "Agus Nugroho", parentPhone: "0812-1111-9005", leadSource: "WHATSAPP", status: "STUDENT", joinedAt: addWeeks(-8) },
{ key: "gita", fullName: "Gita Permata", phone: "0812-1111-0006", email: "gita@example.com", parentName: "Dwi Permata", parentPhone: "0812-1111-9006", leadSource: "REFERRAL", status: "EX_STUDENT", joinedAt: addWeeks(-40) },
{ key: "hadi", fullName: "Hadi Susanto", phone: "0812-1111-0007", email: "hadi@example.com", parentName: "Tono Susanto", parentPhone: "0812-1111-9007", leadSource: "SOCIAL_MEDIA", status: "STUDENT", joinedAt: addWeeks(-6) },
{ key: "intan", fullName: "Intan Puspita", phone: "0812-1111-0008", email: "intan@example.com", parentName: "Maya Puspita", parentPhone: "0812-1111-9008", leadSource: "BANNER", status: "CALON_STUDENT", joinedAt: null as Date | null },
{ key: "joko", fullName: "Joko Wijaya", phone: "0812-1111-0009", email: "joko@example.com", parentName: "Slamet Wijaya", parentPhone: "0812-1111-9009", leadSource: "WHATSAPP", status: "STUDENT", joinedAt: addWeeks(-4) },
{ key: "kirana", fullName: "Kirana Dewi", phone: "0812-1111-0010", email: "kirana@example.com", parentName: "Ratna Dewi", parentPhone: "0812-1111-9010", leadSource: "REFERRAL", status: "EX_STUDENT", joinedAt: addWeeks(-36) },
];
const student: Record<string, { id: string; joinedAt: Date | null }> = {};
for (const s of studentsSeed) {
const created = await prisma.student.create({
data: {
fullName: s.fullName, phone: s.phone, email: s.email, photoUrl: avatar(s.key),
parentName: s.parentName, parentPhone: s.parentPhone, leadSource: s.leadSource,
status: s.status, joinedAt: s.joinedAt,
birthDate: new Date("2012-05-10T00:00:00.000Z"), gender: "MALE", address: "Jl. Merdeka No. 10, Bandung",
},
});
student[s.key] = created;
}
// 4. ENROLLMENT
const enrollments = [
{ key: "andi", code: "KOD-PYT-01", status: "ACTIVE" },
{ key: "dimas", code: "KOD-SCR-01", status: "ACTIVE" },
{ key: "fajar", code: "KOD-WEB-01", status: "ACTIVE" },
{ key: "gita", code: "KOD-JS-01", status: "COMPLETED" },
{ key: "hadi", code: "KOD-ARD-01", status: "ACTIVE" },
{ key: "joko", code: "KOD-PYT-01", status: "ACTIVE" },
{ key: "kirana", code: "KOD-SCR-01", status: "COMPLETED" },
];
for (const e of enrollments) {
await prisma.enrollment.create({
data: { studentId: student[e.key].id, classId: klass[e.code].id, status: e.status, joinedAt: student[e.key].joinedAt ?? BASE, leftAt: e.status === "COMPLETED" ? addWeeks(-2) : null },
});
}
// 5. SESSION
const session: Record<string, { id: string }[]> = {};
for (const code of Object.keys(klass)) {
const c = klass[code];
session[code] = [];
for (let i = 1; i <= c.totalMeetings; i++) {
const s = await prisma.session.create({
data: { classId: c.id, meetingNumber: i, date: addWeeks(-10 + i - 1), topic: `Pertemuan ${i}: ${c.name}`, teacherId: c.teacherId },
});
session[code].push({ id: s.id });
}
}
// 6. KEHADIRAN
const plan: Record<string, { code: string; statuses: Att[] }> = {
andi: { code: "KOD-PYT-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] },
dimas: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "ABSENT", "PRESENT"] },
fajar: { code: "KOD-WEB-01", statuses: ["PRESENT", "LATE", "PRESENT", "PRESENT", "EXCUSED", "PRESENT"] },
gita: { code: "KOD-JS-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
hadi: { code: "KOD-ARD-01", statuses: ["PRESENT", "PRESENT", "PRESENT", "PRESENT"] },
joko: { code: "KOD-PYT-01", statuses: ["PRESENT", "SICK", "PRESENT", "PRESENT", "PRESENT"] },
kirana: { code: "KOD-SCR-01", statuses: ["PRESENT", "PRESENT", "LATE", "PRESENT", "PRESENT", "PRESENT"] },
};
let attendanceCount = 0;
for (const [key, p] of Object.entries(plan)) {
for (let i = 0; i < p.statuses.length; i++) {
await prisma.attendance.create({
data: { sessionId: session[p.code][i].id, studentId: student[key].id, status: p.statuses[i], recordedById: klass[p.code].teacherId, note: p.statuses[i] === "SICK" ? "Demam" : null },
});
attendanceCount++;
}
}
// 7. PEMBAYARAN PRORATA
const paymentsSeed = [
{ key: "andi", code: "KOD-PYT-01", meetingsPaid: 10, method: "TRANSFER", status: "PAID", paidAt: addWeeks(-10) },
{ key: "dimas", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH", status: "PAID", paidAt: addWeeks(-9) },
{ key: "fajar", code: "KOD-WEB-01", meetingsPaid: 6, method: "QRIS", status: "PAID", paidAt: addWeeks(-8), note: "Bayar 6 pertemuan dulu" },
{ key: "gita", code: "KOD-JS-01", meetingsPaid: 10, method: "TRANSFER", status: "PAID", paidAt: addWeeks(-40) },
{ key: "hadi", code: "KOD-ARD-01", meetingsPaid: 8, method: "TRANSFER", status: "PAID", paidAt: addWeeks(-6) },
{ key: "joko", code: "KOD-PYT-01", meetingsPaid: 5, method: "QRIS", status: "PARTIAL", paidAt: addWeeks(-4), note: "Cicilan 1 dari 2" },
{ key: "kirana", code: "KOD-SCR-01", meetingsPaid: 8, method: "CASH", status: "PAID", paidAt: addWeeks(-36) },
];
let paySeq = 1;
const createdPayments: { key: string; code: string; meetingsPaid: number; ppm: number }[] = [];
for (const p of paymentsSeed) {
const c = klass[p.code];
const ppm = c.pricePerMeeting ?? Math.round(c.packagePrice / c.totalMeetings);
await prisma.payment.create({
data: {
invoiceNumber: `INV-2026-${String(paySeq++).padStart(4, "0")}`,
studentId: student[p.key].id, classId: c.id,
meetingsPaid: p.meetingsPaid, pricePerMeeting: ppm, amount: p.meetingsPaid * ppm,
method: p.method, status: p.status, paidAt: p.paidAt, note: p.note ?? null, createdById: sari.id,
},
});
createdPayments.push({ key: p.key, code: p.code, meetingsPaid: p.meetingsPaid, ppm });
}
// 8. JURNAL
const journalsSeed = [
{ code: "KOD-SCR-01", title: "Membuat Game Ular dengan Scratch", description: "Anak-anak belajar event, loop, dan variabel skor sambil membuat game ular versi mereka sendiri.", tags: ["game", "scratch", "logika"], date: addWeeks(-2), students: ["dimas", "kirana"], photos: ["ular-1", "ular-2", "ular-3"] },
{ code: "KOD-PYT-01", title: "Dasar Variabel & Tipe Data Python", description: "Sesi seru bereksperimen dengan string, integer, dan input dari pengguna.", tags: ["python", "variable", "basic"], date: addWeeks(-3), students: ["andi", "joko"], photos: ["python-1", "python-2"] },
{ code: "KOD-WEB-01", title: "Landing Page Pertama", description: "Setiap siswa mempublikasikan halaman profil pribadinya menggunakan HTML & CSS.", tags: ["html", "css", "project"], date: addWeeks(-1), students: ["fajar"], photos: ["web-1", "web-2", "web-3", "web-4"] },
{ code: "KOD-ARD-01", title: "Sensor Cahaya dengan Arduino", description: "Merakit lampu otomatis yang menyala saat ruangan gelap.", tags: ["arduino", "sensor", "robotik"], date: addDays(-5), students: ["hadi"], photos: ["arduino-1", "arduino-2"] },
];
for (const j of journalsSeed) {
const c = klass[j.code];
await prisma.journal.create({
data: {
classId: c.id, sessionId: session[j.code][0].id, title: j.title, description: j.description,
activityDate: j.date, tags: j.tags.join(","), authorId: c.teacherId!,
photos: { create: j.photos.map((s, i) => ({ url: photo(s), caption: `Dokumentasi ${i + 1}`, sortOrder: i })) },
students: { create: j.students.map((k) => ({ studentId: student[k].id })) },
},
});
}
// 9. RAPORT
const reportsSeed = [
{ key: "andi", code: "KOD-PYT-01", period: "2026-Term1", scores: [["Logika", 88], ["Computational Thinking", 85], ["Problem Solving", 82], ["Kreativitas", 90], ["Kolaborasi", 86]], notes: "Andi sangat aktif bertanya dan cepat memahami konsep loop." },
{ key: "dimas", code: "KOD-SCR-01", period: "2026-Term1", scores: [["Logika", 80], ["Computational Thinking", 78], ["Problem Solving", 75], ["Kreativitas", 92], ["Kolaborasi", 88]], notes: "Kreativitas Dimas luar biasa, perlu latihan ketelitian." },
{ key: "fajar", code: "KOD-WEB-01", period: "2026-Term1", scores: [["Logika", 84], ["Computational Thinking", 82], ["Problem Solving", 86], ["Kreativitas", 85], ["Kolaborasi", 80]], notes: "Progres pesat di CSS layout." },
] as { key: string; code: string; period: string; scores: [string, number][]; notes: string }[];
for (const r of reportsSeed) {
const avg = Math.round(r.scores.reduce((a, [, v]) => a + v, 0) / r.scores.length);
const grade = avg >= 90 ? "A" : avg >= 80 ? "B" : avg >= 70 ? "C" : "D";
await prisma.reportCard.create({
data: {
studentId: student[r.key].id, classId: klass[r.code].id, period: r.period,
teacherId: klass[r.code].teacherId!, finalScore: avg, grade, teacherNotes: r.notes,
attendanceSummary: "Hadir 6 dari 6 pertemuan", publishedAt: addDays(-3),
scores: { create: r.scores.map(([aspect, score]) => ({ aspect, score })) },
},
});
}
// 10. PLACEMENT TEST
const placementsSeed = [
{ key: "elsa", level: "INTERMEDIATE" as Level, rec: "KOD-JS-01", scores: [["Logika", 82], ["Dasar Coding", 76], ["Problem Solving", 80], ["Kreativitas", 74]] },
{ key: "intan", level: "BEGINNER" as Level, rec: "KOD-SCR-01", scores: [["Logika", 62], ["Dasar Coding", 48], ["Problem Solving", 55], ["Kreativitas", 70]] },
{ key: "citra", level: "BEGINNER" as Level, rec: "KOD-PYT-01", scores: [["Logika", 50], ["Dasar Coding", 40], ["Problem Solving", 45], ["Kreativitas", 60]] },
{ key: "andi", level: "INTERMEDIATE" as Level, rec: "KOD-JS-01", scores: [["Logika", 80], ["Dasar Coding", 78], ["Problem Solving", 72], ["Kreativitas", 68]] },
] as { key: string; level: Level; rec: string; scores: [string, number][] }[];
for (const p of placementsSeed) {
const total = Math.round(p.scores.reduce((a, [, v]) => a + v, 0) / p.scores.length);
await prisma.placementTest.create({
data: {
studentId: student[p.key].id, testDate: addDays(-12), levelResult: p.level,
recommendedClassId: klass[p.rec].id, totalScore: total, examinerId: rina.id,
notes: `Direkomendasikan masuk kelas ${klass[p.rec].name}.`,
scores: { create: p.scores.map(([category, score]) => ({ category, score })) },
},
});
}
// 11. FOLLOW UP
const followUpsSeed = [
{ key: "citra", status: "CONTACTED", picId: sari.id, next: addDays(-1), last: addDays(-3), channel: "WHATSAPP", activities: [["wa", "Kirim brosur Python Dasar, ortu minta dihubungi lagi."]] },
{ key: "elsa", status: "NEW", picId: budi.id, next: addDays(0), last: null as Date | null, channel: "SOCIAL_MEDIA", activities: [["wa", "Lead dari Instagram, tanya jadwal kelas."]] },
{ key: "intan", status: "TRIAL", picId: rina.id, next: addDays(2), last: addDays(-1), channel: "BANNER", activities: [["call", "Sudah trial Scratch, tinggal konfirmasi pembayaran."]] },
] as { key: string; status: string; picId: string; next: Date; last: Date | null; channel: string; activities: [string, string][] }[];
for (const f of followUpsSeed) {
await prisma.followUp.create({
data: {
studentId: student[f.key].id, status: f.status, picId: f.picId,
nextFollowUpAt: f.next, lastContactedAt: f.last, channel: f.channel,
activities: { create: f.activities.map(([type, note]) => ({ type, note, authorId: f.picId })) },
},
});
}
// 12. SETTINGS
await prisma.setting.createMany({
data: [
{ key: "academy", value: JSON.stringify({ name: "Kodeva Academy", address: "Jl. Coding Raya No. 1, Bandung", phone: "0821-1000-2000", email: "halo@kodeva.id" }) },
{ key: "leadSources", value: JSON.stringify([
{ value: "WHATSAPP", label: "WhatsApp", color: "#22C55E", active: true },
{ value: "REFERRAL", label: "Referral", color: "#7C5CFC", active: true },
{ value: "BANNER", label: "Banner", color: "#FFB020", active: true },
{ value: "SOCIAL_MEDIA", label: "Social Media", color: "#2AA9FF", active: true },
]) },
{ key: "currency", value: JSON.stringify({ code: "IDR", locale: "id-ID", timezone: "Asia/Jakarta" }) },
],
});
const [activeStudents, exStudents, followUpDue] = await Promise.all([
prisma.student.count({ where: { status: "STUDENT" } }),
prisma.student.count({ where: { status: "EX_STUDENT" } }),
prisma.followUp.count({ where: { status: { notIn: ["ENROLLED", "LOST"] }, nextFollowUpAt: { lte: addDays(1) } } }),
]);
console.log("✅ Seed selesai.");
console.log(` 👥 Siswa aktif : ${activeStudents}`);
console.log(` 🎓 Ex student : ${exStudents}`);
console.log(` 🔔 Perlu follow up : ${followUpDue}`);
console.log(` 📚 Kelas : ${Object.keys(klass).length}`);
console.log(` 🗓️ Kehadiran : ${attendanceCount} record`);
console.log("\n Login: admin@kodeva.id / password123");
}
main()
.catch((e) => { console.error("❌ Seed gagal:", e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });