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
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
"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}`);
|
|
}
|