This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+173
-147
@@ -9,176 +9,202 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
|
||||
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"]);
|
||||
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 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", " ") : "—";
|
||||
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}`;
|
||||
const n = Number(amount);
|
||||
const value = Number.isFinite(n) ? n.toFixed(2) : "0.00";
|
||||
return `${value} ${currency}`;
|
||||
}
|
||||
|
||||
export default async function AdminTransactions({
|
||||
searchParams,
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const t = await getTranslations("pages.admin.transactions");
|
||||
const t = await getTranslations("pages.admin.transactions");
|
||||
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
||||
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>();
|
||||
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(),
|
||||
]);
|
||||
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 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 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;
|
||||
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(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<ArrowLeftRight size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
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(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<ArrowLeftRight size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-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>
|
||||
<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(--admin-success-subtle)] text-[var(--admin-text)]" : state === "danger" ? "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-accent)]/18 text-[var(--admin-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>
|
||||
<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(--admin-success-subtle)] text-[var(--admin-text)]" : state === "danger" ? "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-accent)]/18 text-[var(--admin-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>
|
||||
);
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user