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