Files
kodeva/src/app/(app)/payments/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

130 lines
5.8 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 { 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>
);
}