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
+118
View File
@@ -0,0 +1,118 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/rbac";
import { classSchema } from "@/lib/validators";
export type ClassState = { error?: string } | undefined;
function nullable(v: string | undefined | null) {
const t = (v ?? "").trim();
return t === "" ? null : t;
}
function parseDate(v: string | undefined | null) {
const t = (v ?? "").trim();
if (!t) return null;
const d = new Date(t);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function createClassAction(_prev: ClassState, formData: FormData): Promise<ClassState> {
await requireAdmin();
const parsed = classSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
const exists = await prisma.class.findUnique({ where: { code: d.code } });
if (exists) return { error: `Kode kelas ${d.code} sudah dipakai.` };
try {
await prisma.class.create({
data: {
name: d.name,
code: d.code,
description: nullable(d.description),
level: d.level,
teacherId: nullable(d.teacherId),
totalMeetings: d.totalMeetings,
packagePrice: d.packagePrice,
pricePerMeeting: d.pricePerMeeting && d.pricePerMeeting > 0 ? d.pricePerMeeting : null,
capacity: d.capacity,
schedule: nullable(d.schedule),
color: nullable(d.color) ?? "#7C5CFC",
status: d.status,
startDate: parseDate(d.startDate),
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal membuat kelas." };
}
revalidatePath("/classes");
redirect("/classes");
}
export async function updateClassAction(_prev: ClassState, formData: FormData): Promise<ClassState> {
await requireAdmin();
const id = String(formData.get("id"));
const parsed = classSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Data tidak valid." };
const d = parsed.data;
try {
await prisma.class.update({
where: { id },
data: {
name: d.name,
code: d.code,
description: nullable(d.description),
level: d.level,
teacherId: nullable(d.teacherId),
totalMeetings: d.totalMeetings,
packagePrice: d.packagePrice,
pricePerMeeting: d.pricePerMeeting && d.pricePerMeeting > 0 ? d.pricePerMeeting : null,
capacity: d.capacity,
schedule: nullable(d.schedule),
color: nullable(d.color) ?? "#7C5CFC",
status: d.status,
startDate: parseDate(d.startDate),
},
});
} catch (e) {
return { error: e instanceof Error ? e.message : "Gagal memperbarui kelas." };
}
revalidatePath("/classes");
revalidatePath(`/classes/${id}`);
redirect("/classes");
}
export async function archiveClassAction(formData: FormData) {
await requireAdmin();
const id = String(formData.get("id"));
await prisma.class.update({ where: { id }, data: { status: "ARCHIVED" } });
revalidatePath("/classes");
redirect("/classes");
}
export async function generateSessionsAction(formData: FormData) {
await requireAdmin();
const classId = String(formData.get("classId"));
const cls = await prisma.class.findUnique({ where: { id: classId }, include: { sessions: true } });
if (!cls) redirect("/classes");
const existing = new Set(cls.sessions.map((s) => s.meetingNumber));
const base = cls.startDate ?? new Date();
const created = [];
for (let i = 1; i <= cls.totalMeetings; i++) {
if (existing.has(i)) continue;
const date = new Date(base);
date.setDate(date.getDate() + (i - 1) * 7);
const s = await prisma.session.create({
data: { classId, meetingNumber: i, date, topic: `Pertemuan ${i}: ${cls.name}`, teacherId: cls.teacherId },
});
created.push(s.id);
}
revalidatePath(`/classes/${classId}`);
redirect(`/classes/${classId}`);
}