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
+129
View File
@@ -0,0 +1,129 @@
import Link from "next/link";
import { Plus, Undo2, Wallet } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/rbac";
import { formatDateID, formatIDR } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Avatar } from "@/components/ui/avatar";
import { EmptyState } from "@/components/ui/empty-state";
import { MethodChip, PaymentStatusBadge } from "@/components/students/badges";
import { refundPaymentAction } from "./actions";
export const metadata = { title: "Pembayaran — Kodeva" };
type SP = Promise<{ studentId?: string; method?: string; status?: string }>;
export default async function PaymentsPage({ searchParams }: { searchParams: SP }) {
const user = await requireUser();
const sp = await searchParams;
const payments = await prisma.payment.findMany({
where: {
...(sp.studentId ? { studentId: sp.studentId } : {}),
...(sp.method ? { method: sp.method } : {}),
...(sp.status ? { status: sp.status } : {}),
},
include: { student: true, class: true },
orderBy: { paidAt: "desc" },
});
const totalPaid = payments.filter((p) => p.status === "PAID").reduce((a, p) => a + p.amount, 0);
return (
<div>
<PageHeader
title="Pembayaran"
description={`Total diterima: ${formatIDR(totalPaid)}`}
actions={
<Link href="/payments/new">
<Button>
<Plus className="size-4" /> Catat Pembayaran
</Button>
</Link>
}
/>
{payments.length === 0 ? (
<EmptyState icon={<Wallet className="size-6" />} title="Belum ada pembayaran" description="Catat pembayaran prorata pertama siswa." />
) : (
<Card className="overflow-hidden">
<div className="hidden md:block">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-100 text-[12px] uppercase tracking-wide text-ink-500">
<th className="px-5 py-3 font-semibold">Invoice</th>
<th className="px-3 py-3 font-semibold">Siswa</th>
<th className="px-3 py-3 font-semibold">Kelas</th>
<th className="px-3 py-3 font-semibold">Detail</th>
<th className="px-3 py-3 font-semibold">Nominal</th>
<th className="px-3 py-3 font-semibold">Metode</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{payments.map((p) => (
<tr key={p.id} className="transition-colors hover:bg-brand-50/40">
<td className="px-5 py-3">
<span className="font-mono text-[12px] text-ink-500">{p.invoiceNumber}</span>
<p className="text-[11.5px] text-ink-500">{formatDateID(p.paidAt)}</p>
</td>
<td className="px-3 py-3">
<Link href={`/students/${p.studentId}`} className="flex items-center gap-2.5">
<Avatar src={p.student.photoUrl} name={p.student.fullName} size={30} />
<span className="font-medium text-ink-900">{p.student.fullName}</span>
</Link>
</td>
<td className="px-3 py-3 text-ink-700">{p.class.name}</td>
<td className="px-3 py-3 text-[12.5px] text-ink-500">
{p.meetingsPaid} × {formatIDR(p.pricePerMeeting)}
</td>
<td className="px-3 py-3 font-num font-semibold text-ink-900">{formatIDR(p.amount)}</td>
<td className="px-3 py-3">
<MethodChip method={p.method} />
</td>
<td className="px-3 py-3">
<PaymentStatusBadge status={p.status} />
</td>
<td className="px-5 py-3 text-right">
{user.role === "ADMIN" && p.status !== "REFUNDED" ? (
<form action={refundPaymentAction} className="inline">
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="inline-flex items-center gap-1 text-[12px] font-medium text-danger-500 hover:underline">
<Undo2 className="size-3.5" /> Refund
</button>
</form>
) : (
<span className="text-[12px] text-ink-500">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* mobile cards */}
<div className="divide-y divide-ink-100 md:hidden">
{payments.map((p) => (
<div key={p.id} className="p-4">
<div className="flex items-center justify-between">
<span className="font-mono text-[12px] text-ink-500">{p.invoiceNumber}</span>
<PaymentStatusBadge status={p.status} />
</div>
<p className="mt-1.5 font-semibold text-ink-900">{p.student.fullName}</p>
<p className="text-[12.5px] text-ink-500">{p.class.name}</p>
<div className="mt-2 flex items-center justify-between">
<p className="font-num text-[15px] font-bold text-ink-900">{formatIDR(p.amount)}</p>
<MethodChip method={p.method} />
</div>
</div>
))}
</div>
</Card>
)}
</div>
);
}