Improve shop hub: CurrencyIcon on packages/vouchers, DataTable for transactions.
Local Build and Deploy / deploy (push) Successful in 1m3s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:32:45 +02:00
1 parent a2ca9b3c23
commit cbdea70f83
8 files changed
+657 -349

No files matched your search

+128 -122
View File
@@ -1,35 +1,35 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { StatusCard } from "@/components/admin/dashboard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
TransactionsTable,
type TransactionRow,
} from "./transactions-table";
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([
const PAID_STATUS = ["completed", "paid", "success", "approved"] as const;
const FAILED_STATUS = [
"failed",
"denied",
"refunded",
"cancelled",
"canceled",
]);
] as const;
const PENDING_STATUS = ["pending", "created", "processing"] as const;
function statusState(
function statusKind(
status: string | null | undefined,
): "ok" | "danger" | undefined {
if (!status) return undefined;
): TransactionRow["statusKind"] {
if (!status) return "neutral";
const s = status.toLowerCase();
if (PAID_STATUS.has(s)) return "ok";
if (FAILED_STATUS.has(s)) return "danger";
return undefined;
if ((PAID_STATUS as readonly string[]).includes(s)) return "ok";
if ((FAILED_STATUS as readonly string[]).includes(s)) return "danger";
return "neutral";
}
function fromDate(d: Date | null | undefined): string {
@@ -42,10 +42,22 @@ function formatAmount(amount: number, currency: string): string {
return `${value} ${currency}`;
}
const SORT_MAP: Record<
string,
Prisma.WebsitePaypalTransactionsOrderByWithRelationInput
> = {
id: { id: "desc" },
amount: { amount: "desc" },
date: { createdAt: "desc" },
status: { status: "desc" },
package: { description: "desc" },
payer: { userId: "desc" },
};
export default async function AdminTransactions({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
@@ -53,46 +65,106 @@ export default async function AdminTransactions({
}
const t = await getTranslations("pages.admin.transactions");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status ?? "";
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const where: Prisma.WebsitePaypalTransactionsWhereInput = {};
if (statusFilter === "paid") {
where.status = { in: [...PAID_STATUS] };
} else if (statusFilter === "failed") {
where.status = { in: [...FAILED_STATUS] };
} else if (statusFilter === "pending") {
where.status = { in: [...PENDING_STATUS] };
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = Number(q);
let userIds: number[] = [];
try {
const users = await prisma.user.findMany({
where: { username: { contains: q } },
select: { id: true },
take: 50,
});
userIds = users.map((u) => u.id);
} catch {
userIds = [];
}
where.AND = [
...(where.status ? [{ status: where.status }] : []),
{
OR: [
{ transactionId: { contains: q } },
{ description: { contains: q } },
...(Number.isFinite(asId) && asId > 0 ? [{ userId: asId }] : []),
...(userIds.length ? [{ userId: { in: userIds } }] : []),
],
},
];
delete where.status;
}
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(),
]);
total = await prisma.websitePaypalTransactions.count({ where });
} catch {
total = 0;
}
const userIds = [...new Set(transactions.map((t) => t.userId))];
if (userIds.length > 0) {
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const sortKey = parsed.sort ?? "id";
const baseOrder = SORT_MAP[sortKey] ?? { id: "desc" as const };
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
const orderBy = {
[orderField]: parsed.order,
} as Prisma.WebsitePaypalTransactionsOrderByWithRelationInput;
let transactions: Awaited<
ReturnType<typeof prisma.websitePaypalTransactions.findMany>
> = [];
try {
transactions = await prisma.websitePaypalTransactions.findMany({
where,
orderBy,
skip: pagination.offset,
take: pagination.perPage,
});
} catch {
transactions = [];
}
const userIds = [...new Set(transactions.map((x) => x.userId))];
let names = new Map<number, string>();
if (userIds.length > 0) {
try {
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 {
names = new Map();
}
} catch {
transactions = [];
total = 0;
names = new Map();
}
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const rows: TransactionRow[] = transactions.map((x) => ({
id: Number(x.id),
payer: names.get(x.userId) ?? `#${x.userId}`,
payerId: x.userId,
amount: formatAmount(x.amount, x.currency),
package: x.description ?? "—",
status: x.status ?? "",
statusKind: statusKind(x.status),
transactionId: x.transactionId,
date: fromDate(x.createdAt),
}));
const pagePaid = transactions.filter(
(t) => statusState(t.status) === "ok",
).length;
const pageFailed = transactions.filter(
(t) => statusState(t.status) === "danger",
).length;
const pagePaid = rows.filter((r) => r.statusKind === "ok").length;
const pageFailed = rows.filter((r) => r.statusKind === "danger").length;
return (
<main>
@@ -113,87 +185,21 @@ export default async function AdminTransactions({
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({total})
</h2>
{transactions.length === 0 ? (
<h2 className="admin-section-title">{t("title")}</h2>
{total === 0 && !parsed.search && !statusFilter ? (
<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>
<TransactionsTable
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</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>
);
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types";
export interface TransactionRow {
id: number;
payer: string;
payerId: number;
amount: string;
package: string;
status: string;
statusKind: "ok" | "danger" | "neutral";
transactionId: string;
date: string;
}
function statusVariant(
kind: TransactionRow["statusKind"],
): "default" | "destructive" | "secondary" {
if (kind === "ok") return "default";
if (kind === "danger") return "destructive";
return "secondary";
}
export function TransactionsTable({
data,
}: {
data: PaginatedResult<TransactionRow>;
}) {
const t = useTranslations("pages.admin.transactions");
const columns: DataTableColumn<TransactionRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
key: "payer",
label: t("colPayer"),
sortable: true,
render: (_value, row) => (
<Link
href={`/admin/users/${row.payerId}`}
className="font-medium hover:underline"
>
{row.payer}
</Link>
),
},
{ key: "amount", label: t("colAmount"), sortable: true },
{ key: "package", label: t("colPackage"), sortable: true },
{
key: "status",
label: t("colStatus"),
sortable: true,
filterKey: "filter_status",
filterOptions: [
{ label: t("statusCompleted"), value: "paid" },
{ label: t("statusPending"), value: "pending" },
{ label: t("statusFailed"), value: "failed" },
],
render: (_value, row) =>
row.status ? (
<Badge
variant={statusVariant(row.statusKind)}
className="font-mono text-[0.7rem]"
>
{row.status}
</Badge>
) : (
<span className="text-muted-foreground">—</span>
),
},
{ key: "transactionId", label: t("colTransaction") },
{ key: "date", label: t("colDate"), sortable: true },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{ label: t("presetPaid"), params: { filter_status: "paid" } },
{ label: t("presetFailed"), params: { filter_status: "failed" } },
]}
/>
);
}