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