Files
EpicNext-Cms/src/app/admin/subscriptions/page.tsx
T
simoleo89andCursor 0e89d03940 Finish fine-grained ACL across remaining admin pages and actions.
Replace leftover requireStaff gates with module PERMS, drop hardcoded room rank thresholds, and expand contract tests so admin mutations cannot regress to dashboard-only checks.

Co-authored-by: Cursor <[email protected]>
2026-07-15 20:15:22 +02:00

130 lines
3.8 KiB
TypeScript

import { CreditCard } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type SubRow = {
id: number;
userId: number | null;
subscriptionType: string | null;
expiresAt: number;
};
function fmt(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function AdminSubscriptions() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.subscriptions");
const now = Math.floor(Date.now() / 1000);
let active: SubRow[] = [];
try {
const rows = await prisma.usersSubscriptions.findMany({
where: { active: 1 },
orderBy: { id: "desc" },
take: 500,
});
active = rows
.map((r) => ({
id: r.id,
userId: r.userId,
subscriptionType: r.subscriptionType,
expiresAt: (r.timestampStart ?? 0) + (r.duration ?? 0),
}))
.filter((r) => r.expiresAt > now)
.sort((a, b) => a.expiresAt - b.expiresAt);
} catch {
active = [];
}
const soonCutoff = now + 7 * 24 * 60 * 60;
const expiringSoon = active.filter((s) => s.expiresAt <= soonCutoff).length;
const types = new Set(active.map((s) => s.subscriptionType ?? "—")).size;
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">
<CreditCard 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("activeSubs")} value={active.length} icon="⭐" />
<StatusCard label={t("types")} value={types} icon="🏷️" />
<StatusCard
label={t("expiringSoon")}
value={expiringSoon}
state={expiringSoon > 0 ? "warn" : "ok"}
icon="⏳"
/>
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activeSubscriptions")} ({active.length})
</h2>
<div className="admin-card">
<table>
<thead>
<tr>
<th>{t("colSubId")}</th>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colStatus")}</th>
<th>{t("colExpires")}</th>
</tr>
</thead>
<tbody>
{active.map((s) => {
const soon = s.expiresAt <= soonCutoff;
return (
<tr key={s.id}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{s.id}
</td>
<td>{s.userId ?? "—"}</td>
<td>{s.subscriptionType ?? "—"}</td>
<td>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${soon ? "bg-[var(--admin-accent)]/18 text-[var(--admin-text)]" : "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]"}`}
>
{soon ? t("statusExpiring") : t("statusActive")}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmt(s.expiresAt)}
</td>
</tr>
);
})}
</tbody>
</table>
{active.length === 0 ? (
<div className="admin-empty">{t("noSubscriptions")}</div>
) : null}
</div>
</section>
</main>
);
}