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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user