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,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(); });
|
||||
Reference in New Issue
Block a user