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
+504
View File
@@ -0,0 +1,504 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
CalendarCheck,
FileText,
GraduationCap,
LayoutList,
Mail,
MessageCircle,
NotebookPen,
Pencil,
Phone,
Target,
UserRound,
Wallet,
} from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID, formatIDR, formatRelativeID } from "@/lib/utils";
import { getPricePerMeeting, gradeFromScore } from "@/lib/prorata";
import { LEAD_SOURCE_META } from "@/lib/constants";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { archiveStudentAction } from "../actions";
import { LevelBadge, MethodChip, PaymentStatusBadge, SourceChip, StatusBadge } from "@/components/students/badges";
import { cn } from "@/lib/utils";
export const metadata = { title: "Detail Siswa — Kodeva" };
const TABS = [
{ key: "ringkasan", label: "Ringkasan", icon: LayoutList },
{ key: "kelas", label: "Kelas & Kehadiran", icon: CalendarCheck },
{ key: "pembayaran", label: "Pembayaran", icon: Wallet },
{ key: "placement", label: "Placement Test", icon: Target },
{ key: "raport", label: "Raport", icon: FileText },
{ key: "jurnal", label: "Jurnal", icon: NotebookPen },
];
function waLink(phone: string) {
return `https://wa.me/${phone.replace(/[^0-9]/g, "").replace(/^0/, "62")}`;
}
export default async function StudentDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ tab?: string }>;
}) {
const user = await requireUser();
const { id } = await params;
const sp = await searchParams;
const tab = TABS.some((t) => t.key === sp.tab) ? sp.tab! : "ringkasan";
const student = await prisma.student.findUnique({
where: { id },
include: {
enrollments: { include: { class: { include: { teacher: true } } } },
payments: { include: { class: true }, orderBy: { paidAt: "desc" } },
attendances: { include: { session: true }, orderBy: { session: { date: "desc" } } },
placementTests: { include: { scores: true, examiner: true }, orderBy: { testDate: "desc" } },
reportCards: { include: { scores: true, teacher: true, class: true }, orderBy: { createdAt: "desc" } },
journalStudents: {
include: { journal: { include: { class: true, photos: true } } },
orderBy: { journal: { activityDate: "desc" } },
},
followUps: { include: { activities: true, pic: true }, orderBy: { updatedAt: "desc" }, take: 1 },
},
});
if (!student) notFound();
// Referensi kelas untuk rekomendasi placement
const classes = await prisma.class.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } });
// Prorata per kelas
const prorataByClass = new Map<string, { paidMeetings: number; usedMeetings: number; ppm: number; className: string }>();
for (const en of student.enrollments) {
const paid = student.payments
.filter((p) => p.classId === en.classId && p.status !== "REFUNDED")
.reduce((a, p) => a + p.meetingsPaid, 0);
const used = student.attendances.filter(
(a) => a.session.classId === en.classId && (a.status === "PRESENT" || a.status === "LATE"),
).length;
prorataByClass.set(en.classId, {
paidMeetings: paid,
usedMeetings: used,
ppm: getPricePerMeeting(en.class),
className: en.class.name,
});
}
const latestPlacement = student.placementTests[0];
const attendanceCount = student.attendances.length;
const presentCount = student.attendances.filter((a) => a.status === "PRESENT" || a.status === "LATE").length;
const attendanceRate = attendanceCount ? Math.round((presentCount / attendanceCount) * 100) : 0;
const sourceMeta = LEAD_SOURCE_META[student.leadSource];
return (
<div className="space-y-6">
{/* Header profil */}
<div className="animate-fade-up overflow-hidden rounded-[24px] border border-ink-100 bg-surface shadow-[var(--shadow-sm)]">
<div className="grad-primary h-24" />
<div className="px-5 pb-5 sm:px-7">
<div className="-mt-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex items-end gap-4">
<Avatar src={student.photoUrl} name={student.fullName} size={84} ring className="border-4 border-surface" />
<div className="pb-0.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-[22px] font-extrabold text-ink-900">{student.fullName}</h1>
<StatusBadge status={student.status} />
{latestPlacement ? <LevelBadge level={latestPlacement.levelResult} /> : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12.5px] text-ink-500">
<span className="flex items-center gap-1.5">
<Phone className="size-3.5" /> {student.phone}
</span>
{student.email ? (
<span className="flex items-center gap-1.5">
<Mail className="size-3.5" /> {student.email}
</span>
) : null}
<span className="flex items-center gap-1.5">
<UserRound className="size-3.5" /> Ortu: {student.parentName ?? "-"}
</span>
<SourceChip source={student.leadSource} />
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<a href={waLink(student.phone)} target="_blank" rel="noreferrer">
<Button variant="secondary">
<MessageCircle className="size-4" /> WhatsApp
</Button>
</a>
<a href={`tel:${student.phone}`}>
<Button variant="outline">
<Phone className="size-4" /> Telepon
</Button>
</a>
<Link href={`/students/${student.id}/edit`}>
<Button variant="outline">
<Pencil className="size-4" /> Edit
</Button>
</Link>
{user.role === "ADMIN" ? (
<form action={archiveStudentAction}>
<input type="hidden" name="id" value={student.id} />
<Button variant="ghost" className="text-danger-500 hover:bg-danger-100">
Arsipkan
</Button>
</form>
) : null}
</div>
</div>
{/* Stat mini */}
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{ label: "Kehadiran", value: `${attendanceRate}%`, icon: CalendarCheck, color: "#12B886" },
{ label: "Pertemuan hadir", value: `${presentCount}/${attendanceCount}`, icon: GraduationCap, color: "#7C5CFC" },
{ label: "Kelas aktif", value: student.enrollments.filter((e) => e.status === "ACTIVE").length, icon: LayoutList, color: "#2AA9FF" },
{ label: "Total pembayaran", value: formatIDR(student.payments.filter((p) => p.status === "PAID").reduce((a, p) => a + p.amount, 0)), icon: Wallet, color: "#FFB020" },
].map((s) => (
<div key={s.label} className="flex items-center gap-3 rounded-[14px] border border-ink-100 bg-canvas px-3 py-2.5">
<span className="flex size-9 items-center justify-center rounded-[10px]" style={{ backgroundColor: `${s.color}1a`, color: s.color }}>
<s.icon className="size-[18px]" />
</span>
<div className="min-w-0">
<p className="truncate font-num text-[15px] font-bold text-ink-900">{s.value}</p>
<p className="truncate text-[11px] text-ink-500">{s.label}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Tab nav */}
<div className="flex gap-1 overflow-x-auto rounded-[16px] border border-ink-100 bg-surface p-1.5">
{TABS.map((t) => {
const Icon = t.icon;
const active = tab === t.key;
return (
<Link
key={t.key}
href={`/students/${student.id}?tab=${t.key}`}
className={cn(
"flex shrink-0 items-center gap-2 rounded-[12px] px-3.5 py-2 text-[13px] font-medium transition-colors",
active ? "grad-primary text-white" : "text-ink-700 hover:bg-brand-50 hover:text-brand-700",
)}
>
<Icon className="size-4" />
{t.label}
</Link>
);
})}
</div>
{/* ── TAB: RINGKASAN ─────────────────────────────── */}
{tab === "ringkasan" ? (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Informasi</CardTitle>
</CardHeader>
<CardBody className="space-y-3 text-[13px]">
<Info label="No. telepon" value={student.phone} />
<Info label="Email" value={student.email ?? "-"} />
<Info label="Orang tua" value={student.parentName ?? "-"} />
<Info label="Telepon orang tua" value={student.parentPhone ?? "-"} />
<Info label="Alamat" value={student.address ?? "-"} />
<Info label="Tahu dari" value={sourceMeta?.label ?? student.leadSource} />
<Info label="Bergabung" value={formatDateID(student.joinedAt ?? student.createdAt)} />
{student.notes ? (
<div className="rounded-[12px] bg-amber-100 px-3 py-2.5">
<p className="text-[11px] font-semibold uppercase text-amber-500">Catatan</p>
<p className="mt-0.5 text-ink-700">{student.notes}</p>
</div>
) : null}
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Kelas & Kuota Sesi</CardTitle>
<Link href={`/students/${student.id}?tab=kelas`} className="text-[13px] font-semibold text-brand-600 hover:underline">
Detail
</Link>
</CardHeader>
<CardBody className="space-y-3 pt-0">
{student.enrollments.length === 0 ? (
<EmptyState title="Belum terdaftar di kelas mana pun" description="Tambahkan kelas lewat tombol Edit." />
) : (
student.enrollments.map((en) => {
const p = prorataByClass.get(en.classId)!;
const remaining = Math.max(0, p.paidMeetings - p.usedMeetings);
const progress = p.paidMeetings ? Math.min(100, Math.round((p.usedMeetings / p.paidMeetings) * 100)) : 0;
return (
<div key={en.id} className="rounded-[16px] border border-ink-100 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="size-9 rounded-[10px]" style={{ backgroundColor: `${en.class.color ?? "#7C5CFC"}1a` }} />
<div>
<p className="text-[14px] font-semibold text-ink-900">{en.class.name}</p>
<p className="text-[12px] text-ink-500">{en.class.schedule ?? "-"}</p>
</div>
</div>
<StatusBadge status={en.status === "ACTIVE" ? "STUDENT" : "EX_STUDENT"} />
</div>
<div className="mt-3">
<div className="mb-1 flex justify-between text-[12px] text-ink-500">
<span>
Terpakai <b className="text-ink-900">{p.usedMeetings}</b> / <b className="text-ink-900">{p.paidMeetings}</b> sesi
</span>
<span>Sisa <b className="text-ink-900">{remaining}</b> sesi</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-ink-100">
<div className="h-full rounded-full" style={{ width: `${progress}%`, backgroundColor: en.class.color ?? "#7C5CFC" }} />
</div>
<p className="mt-2 text-[12px] text-ink-500">
Nilai sisa: <span className="font-semibold text-ink-900">{formatIDR(remaining * p.ppm)}</span>
{" "}· {formatIDR(p.ppm)}/pertemuan
</p>
</div>
</div>
);
})
)}
</CardBody>
</Card>
</div>
) : null}
{/* ── TAB: KELAS & KEHADIRAN ─────────────────────── */}
{tab === "kelas" ? (
<Card>
<CardHeader>
<CardTitle>Riwayat Kehadiran</CardTitle>
<span className="text-[12px] text-ink-500">{attendanceCount} catatan</span>
</CardHeader>
<CardBody className="pt-0">
{student.attendances.length === 0 ? (
<EmptyState icon={<CalendarCheck className="size-6" />} title="Belum ada catatan kehadiran" />
) : (
<div className="divide-y divide-ink-100">
{student.attendances.slice(0, 30).map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5">
<span className="font-num text-[13px] font-semibold text-ink-700">
#{a.session.meetingNumber}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-ink-900">{a.session.topic ?? "Pertemuan"}</p>
<p className="text-[12px] text-ink-500">{formatDateID(a.session.date, "EEE, d MMM yyyy")}</p>
</div>
{a.note ? <span className="text-[12px] text-ink-500">{a.note}</span> : null}
<AttendancePill status={a.status} />
</div>
))}
</div>
)}
</CardBody>
</Card>
) : null}
{/* ── TAB: PEMBAYARAN ────────────────────────────── */}
{tab === "pembayaran" ? (
<Card>
<CardHeader>
<CardTitle>Riwayat Pembayaran</CardTitle>
<Link href={`/payments/new?studentId=${student.id}`}>
<Button size="sm">
<Wallet className="size-4" /> Catat Pembayaran
</Button>
</Link>
</CardHeader>
<CardBody className="pt-0">
{student.payments.length === 0 ? (
<EmptyState icon={<Wallet className="size-6" />} title="Belum ada pembayaran" />
) : (
<div className="space-y-3">
{student.payments.map((p) => (
<div key={p.id} className="flex flex-wrap items-center gap-3 rounded-[14px] border border-ink-100 p-3.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[12px] text-ink-500">{p.invoiceNumber}</span>
<PaymentStatusBadge status={p.status} />
</div>
<p className="mt-1 text-[14px] font-semibold text-ink-900">
{formatIDR(p.amount)}{" "}
<span className="text-[12px] font-normal text-ink-500">
= {p.meetingsPaid} pertemuan × {formatIDR(p.pricePerMeeting)}
</span>
</p>
<p className="text-[12px] text-ink-500">
{p.class.name} · {formatDateID(p.paidAt)}
</p>
</div>
<MethodChip method={p.method} />
</div>
))}
</div>
)}
</CardBody>
</Card>
) : null}
{/* ── TAB: PLACEMENT TEST ────────────────────────── */}
{tab === "placement" ? (
<div className="space-y-4">
{student.placementTests.length === 0 ? (
<EmptyState icon={<Target className="size-6" />} title="Belum ada hasil placement test" description="Input hasil tes penempatan di menu Placement Test." />
) : (
student.placementTests.map((pt) => (
<Card key={pt.id}>
<CardHeader>
<div className="flex items-center gap-3">
<span className="grad-primary flex size-10 items-center justify-center rounded-[12px] text-white">
<Target className="size-5" />
</span>
<div>
<CardTitle>Hasil Placement Test</CardTitle>
<p className="text-[12px] text-ink-500">
{formatDateID(pt.testDate)} · Pemeriksa {pt.examiner?.name ?? "-"}
</p>
</div>
</div>
<div className="text-right">
<p className="font-num text-[26px] font-bold text-ink-900">{pt.totalScore}</p>
<LevelBadge level={pt.levelResult} />
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-2.5">
{pt.scores.map((s) => (
<div key={s.id}>
<div className="mb-1 flex justify-between text-[12.5px]">
<span className="font-medium text-ink-700">{s.category}</span>
<span className="font-num font-semibold text-ink-900">{s.score}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-ink-100">
<div className="grad-primary h-full rounded-full" style={{ width: `${s.score}%` }} />
</div>
</div>
))}
</div>
{pt.recommendedClassId ? (
<div className="mt-4 rounded-[12px] bg-brand-50 px-3 py-2.5 text-[13px] text-brand-700">
Rekomendasi kelas:{" "}
<b>{classes.find((c) => c.id === pt.recommendedClassId)?.name ?? "-"}</b>
</div>
) : null}
{pt.notes ? <p className="mt-3 text-[12.5px] text-ink-500">{pt.notes}</p> : null}
</CardBody>
</Card>
))
)}
</div>
) : null}
{/* ── TAB: RAPORT ────────────────────────────────── */}
{tab === "raport" ? (
<div className="space-y-4">
{student.reportCards.length === 0 ? (
<EmptyState icon={<FileText className="size-6" />} title="Belum ada raport" />
) : (
student.reportCards.map((rc) => (
<Card key={rc.id}>
<CardHeader>
<div>
<CardTitle>{rc.class.name}</CardTitle>
<p className="text-[12px] text-ink-500">
{rc.period} · {rc.teacher?.name ?? "-"}
</p>
</div>
<div className="text-right">
<p className="font-num text-[26px] font-bold text-ink-900">{rc.finalScore}</p>
<Badge bg="#E4F8F1" fg="#0A7A58">
Grade {rc.grade ?? gradeFromScore(rc.finalScore ?? 0)}
</Badge>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{rc.scores.map((s) => (
<div key={s.id} className="rounded-[12px] bg-canvas px-3 py-2.5">
<p className="text-[12px] text-ink-500">{s.aspect}</p>
<p className="font-num text-[16px] font-bold text-ink-900">{s.score}</p>
</div>
))}
</div>
{rc.teacherNotes ? (
<p className="mt-3 rounded-[12px] bg-canvas px-3 py-2.5 text-[13px] text-ink-700">
{rc.teacherNotes}
</p>
) : null}
</CardBody>
</Card>
))
)}
</div>
) : null}
{/* ── TAB: JURNAL ────────────────────────────────── */}
{tab === "jurnal" ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{student.journalStudents.length === 0 ? (
<div className="sm:col-span-2">
<EmptyState icon={<NotebookPen className="size-6" />} title="Belum ada jurnal untuk siswa ini" />
</div>
) : (
student.journalStudents.map((js) => (
<Card key={js.journalId} className="overflow-hidden">
{js.journal.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={js.journal.photos[0].url} alt={js.journal.title} className="h-40 w-full object-cover" />
) : (
<div className="grad-pink flex h-40 items-center justify-center text-white">
<NotebookPen className="size-8 opacity-80" />
</div>
)}
<CardBody className="pt-3">
<p className="text-[11px] font-medium text-ink-500">{js.journal.class.name}</p>
<h4 className="mt-0.5 text-[15px] font-semibold text-ink-900">{js.journal.title}</h4>
<p className="mt-1 line-clamp-2 text-[13px] text-ink-500">{js.journal.description}</p>
<p className="mt-2 text-[12px] text-ink-500">{formatRelativeID(js.journal.activityDate)}</p>
</CardBody>
</Card>
))
)}
</div>
) : null}
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 border-b border-ink-100 pb-2.5 last:border-0">
<span className="shrink-0 text-ink-500">{label}</span>
<span className="text-right font-medium text-ink-900">{value}</span>
</div>
);
}
function AttendancePill({ status }: { status: string }) {
const meta: Record<string, { label: string; bg: string; text: string }> = {
PRESENT: { label: "Hadir", bg: "#E4F8F1", text: "#0A7A58" },
LATE: { label: "Terlambat", bg: "#FFF4E0", text: "#9A6100" },
EXCUSED: { label: "Izin", bg: "#E3F3FF", text: "#0B6FB8" },
SICK: { label: "Sakit", bg: "#F1ECFE", text: "#5B34C9" },
ABSENT: { label: "Alpa", bg: "#FEE4E2", text: "#B42318" },
};
const m = meta[status] ?? { label: status, bg: "#EEF1F7", text: "#4A5470" };
return (
<Badge bg={m.bg} fg={m.text}>
{m.label}
</Badge>
);
}