Files
kodeva/src/app/(app)/students/[id]/page.tsx
T
Amadea 61ce9d7926 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
2026-09-18 19:24:59 +07:00

505 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}