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(); 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 (
{/* Header profil */}

{student.fullName}

{latestPlacement ? : null}
{student.phone} {student.email ? ( {student.email} ) : null} Ortu: {student.parentName ?? "-"}
{user.role === "ADMIN" ? (
) : null}
{/* Stat mini */}
{[ { 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) => (

{s.value}

{s.label}

))}
{/* Tab nav */}
{TABS.map((t) => { const Icon = t.icon; const active = tab === t.key; return ( {t.label} ); })}
{/* ── TAB: RINGKASAN ─────────────────────────────── */} {tab === "ringkasan" ? (
Informasi {student.notes ? (

Catatan

{student.notes}

) : null}
Kelas & Kuota Sesi Detail {student.enrollments.length === 0 ? ( ) : ( 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 (

{en.class.name}

{en.class.schedule ?? "-"}

Terpakai {p.usedMeetings} / {p.paidMeetings} sesi Sisa {remaining} sesi

Nilai sisa: {formatIDR(remaining * p.ppm)} {" "}· {formatIDR(p.ppm)}/pertemuan

); }) )}
) : null} {/* ── TAB: KELAS & KEHADIRAN ─────────────────────── */} {tab === "kelas" ? ( Riwayat Kehadiran {attendanceCount} catatan {student.attendances.length === 0 ? ( } title="Belum ada catatan kehadiran" /> ) : (
{student.attendances.slice(0, 30).map((a) => (
#{a.session.meetingNumber}

{a.session.topic ?? "Pertemuan"}

{formatDateID(a.session.date, "EEE, d MMM yyyy")}

{a.note ? {a.note} : null}
))}
)}
) : null} {/* ── TAB: PEMBAYARAN ────────────────────────────── */} {tab === "pembayaran" ? ( Riwayat Pembayaran {student.payments.length === 0 ? ( } title="Belum ada pembayaran" /> ) : (
{student.payments.map((p) => (
{p.invoiceNumber}

{formatIDR(p.amount)}{" "} = {p.meetingsPaid} pertemuan × {formatIDR(p.pricePerMeeting)}

{p.class.name} · {formatDateID(p.paidAt)}

))}
)}
) : null} {/* ── TAB: PLACEMENT TEST ────────────────────────── */} {tab === "placement" ? (
{student.placementTests.length === 0 ? ( } title="Belum ada hasil placement test" description="Input hasil tes penempatan di menu Placement Test." /> ) : ( student.placementTests.map((pt) => (
Hasil Placement Test

{formatDateID(pt.testDate)} · Pemeriksa {pt.examiner?.name ?? "-"}

{pt.totalScore}

{pt.scores.map((s) => (
{s.category} {s.score}
))}
{pt.recommendedClassId ? (
Rekomendasi kelas:{" "} {classes.find((c) => c.id === pt.recommendedClassId)?.name ?? "-"}
) : null} {pt.notes ?

{pt.notes}

: null} )) )}
) : null} {/* ── TAB: RAPORT ────────────────────────────────── */} {tab === "raport" ? (
{student.reportCards.length === 0 ? ( } title="Belum ada raport" /> ) : ( student.reportCards.map((rc) => (
{rc.class.name}

{rc.period} · {rc.teacher?.name ?? "-"}

{rc.finalScore}

Grade {rc.grade ?? gradeFromScore(rc.finalScore ?? 0)}
{rc.scores.map((s) => (

{s.aspect}

{s.score}

))}
{rc.teacherNotes ? (

“{rc.teacherNotes}”

) : null}
)) )}
) : null} {/* ── TAB: JURNAL ────────────────────────────────── */} {tab === "jurnal" ? (
{student.journalStudents.length === 0 ? (
} title="Belum ada jurnal untuk siswa ini" />
) : ( student.journalStudents.map((js) => ( {js.journal.photos[0] ? ( // eslint-disable-next-line @next/next/no-img-element {js.journal.title} ) : (
)}

{js.journal.class.name}

{js.journal.title}

{js.journal.description}

{formatRelativeID(js.journal.activityDate)}

)) )}
) : null}
); } function Info({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function AttendancePill({ status }: { status: string }) { const meta: Record = { 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 ( {m.label} ); }