185 lines
6.5 KiB
TypeScript
185 lines
6.5 KiB
TypeScript
import { ArrowLeftRight } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { StatusCard } from "@/components/admin/dashboard";
|
|
import { requireStaff } from "@/lib/admin/guard";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const PER_PAGE = 25;
|
|
|
|
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
|
|
|
|
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
|
|
const FAILED_STATUS = new Set(["failed", "denied", "refunded", "cancelled", "canceled"]);
|
|
|
|
function statusState(status: string | null | undefined): "ok" | "danger" | undefined {
|
|
if (!status) return undefined;
|
|
const s = status.toLowerCase();
|
|
if (PAID_STATUS.has(s)) return "ok";
|
|
if (FAILED_STATUS.has(s)) return "danger";
|
|
return undefined;
|
|
}
|
|
|
|
function fromDate(d: Date | null | undefined): string {
|
|
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
|
}
|
|
|
|
function formatAmount(amount: number, currency: string): string {
|
|
const n = Number(amount);
|
|
const value = Number.isFinite(n) ? n.toFixed(2) : "0.00";
|
|
return `${value} ${currency}`;
|
|
}
|
|
|
|
export default async function AdminTransactions({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ page?: string }>;
|
|
}) {
|
|
await requireStaff();
|
|
|
|
const t = await getTranslations("pages.admin.transactions");
|
|
|
|
const sp = await searchParams;
|
|
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
|
|
|
let transactions: Txn[] = [];
|
|
let total = 0;
|
|
let names = new Map<number, string>();
|
|
|
|
try {
|
|
[transactions, total] = await Promise.all([
|
|
prisma.websitePaypalTransactions.findMany({
|
|
orderBy: { id: "desc" },
|
|
skip: (page - 1) * PER_PAGE,
|
|
take: PER_PAGE,
|
|
}),
|
|
prisma.websitePaypalTransactions.count(),
|
|
]);
|
|
|
|
const userIds = [...new Set(transactions.map((t) => t.userId))];
|
|
if (userIds.length > 0) {
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, username: true },
|
|
});
|
|
names = new Map(users.map((u) => [u.id, u.username]));
|
|
}
|
|
} catch {
|
|
transactions = [];
|
|
total = 0;
|
|
names = new Map();
|
|
}
|
|
|
|
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
|
|
|
const pagePaid = transactions.filter((t) => statusState(t.status) === "ok").length;
|
|
const pageFailed = transactions.filter((t) => statusState(t.status) === "danger").length;
|
|
|
|
return (
|
|
<main>
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
|
<ArrowLeftRight size={20} className="text-[var(--color-primary)]" />
|
|
</div>
|
|
<div>
|
|
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
|
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
|
<StatusCard label={t("totalTransactions")} value={total} icon="🧾" />
|
|
<StatusCard
|
|
label={t("pagePaid")}
|
|
value={pagePaid}
|
|
state={pagePaid > 0 ? "ok" : "neutral"}
|
|
icon="💳"
|
|
/>
|
|
<StatusCard
|
|
label={t("pageFailed")}
|
|
value={pageFailed}
|
|
state={pageFailed > 0 ? "danger" : "neutral"}
|
|
icon="⚠️"
|
|
/>
|
|
</div>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="admin-section-title">
|
|
{t("title")} ({total})
|
|
</h2>
|
|
{transactions.length === 0 ? (
|
|
<div className="admin-empty">{t("noTransactions")}</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>{t("colId")}</th>
|
|
<th>{t("colPayer")}</th>
|
|
<th>{t("colAmount")}</th>
|
|
<th>{t("colPackage")}</th>
|
|
<th>{t("colStatus")}</th>
|
|
<th>{t("colTransaction")}</th>
|
|
<th>{t("colDate")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{transactions.map((x) => {
|
|
const state = statusState(x.status);
|
|
return (
|
|
<tr key={String(x.id)}>
|
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
|
{String(x.id)}
|
|
</td>
|
|
<td>
|
|
<Link href={`/admin/users/${x.userId}`}>{names.get(x.userId) ?? `#${x.userId}`}</Link>
|
|
</td>
|
|
<td>{formatAmount(x.amount, x.currency)}</td>
|
|
<td>{x.description ?? "—"}</td>
|
|
<td>
|
|
{x.status ? (
|
|
<span
|
|
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${state === "ok" ? "bg-[var(--color-success-subtle)] text-[var(--color-text)]" : state === "danger" ? "bg-[var(--color-error-subtle)] text-[var(--color-text)]" : "bg-[var(--color-primary)]/18 text-[var(--color-text)]"}`}
|
|
>
|
|
{x.status}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span>
|
|
)}
|
|
</td>
|
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
|
{x.transactionId}
|
|
</td>
|
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
|
{fromDate(x.createdAt)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
|
{t("pagination", { page, pages, total })}
|
|
{page > 1 ? (
|
|
<>
|
|
{" · "}
|
|
<Link href={`/admin/transactions?page=${page - 1}`}>← {t("prev")}</Link>
|
|
</>
|
|
) : null}
|
|
{page < pages ? (
|
|
<>
|
|
{" · "}
|
|
<Link href={`/admin/transactions?page=${page + 1}`}>{t("next")} →</Link>
|
|
</>
|
|
) : null}
|
|
</p>
|
|
</main>
|
|
);
|
|
}
|