Files
EpicNext-Cms/src/app/admin/vouchers/page.tsx
T
SimoandCursor 0e89d03940
Local Build and Deploy / deploy (push) Successful in 56s
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

243 lines
6.7 KiB
TypeScript

import { Gift } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
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 VoucherRow = {
id: bigint;
code: string;
amount: number;
maxUses: number;
useCount: number;
expiresAt: Date | null;
createdAt: Date | null;
};
function fmtDate(d: Date | null): string {
if (!d) return "—";
return d.toISOString().slice(0, 16).replace("T", " ");
}
export default async function AdminVouchers() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.vouchers");
const now = Date.now();
let vouchers: VoucherRow[] = [];
try {
vouchers = await prisma.websiteShopVouchers.findMany({
select: {
id: true,
code: true,
amount: true,
maxUses: true,
useCount: true,
expiresAt: true,
createdAt: true,
},
orderBy: { id: "desc" },
take: 200,
});
} catch {
vouchers = [];
}
const active = vouchers.filter((v) => {
const expired = v.expiresAt ? v.expiresAt.getTime() <= now : false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return !expired && !usedUp;
}).length;
const expiredCount = vouchers.filter(
(v) => v.expiresAt && v.expiresAt.getTime() <= now,
).length;
const usedUpCount = vouchers.filter(
(v) => v.maxUses > 0 && v.useCount >= v.maxUses,
).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">
<Gift 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("vouchers")} value={vouchers.length} icon="🎟️" />
<StatusCard
label={t("active")}
value={active}
state={active > 0 ? "ok" : "neutral"}
icon="✅"
/>
<StatusCard
label={t("usedUp")}
value={usedUpCount}
state={usedUpCount > 0 ? "warn" : "neutral"}
icon="📉"
/>
<StatusCard
label={t("expired")}
value={expiredCount}
state={expiredCount > 0 ? "danger" : "neutral"}
icon="⏰"
/>
</div>
<form action={createVoucher} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>{t("newVoucher")}</h3>
<div
className="grid grid-cols-1 gap-4 md:grid-cols-2"
style={{ marginBottom: "0.75rem" }}
>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.code")}
</span>
<input
name="code"
placeholder={t("form.codePlaceholder")}
maxLength={255}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.amount")}
</span>
<input
name="amount"
type="number"
min={1}
placeholder={t("form.amountPlaceholder")}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.maxUses")}
</span>
<input
name="maxUses"
type="number"
min={1}
defaultValue={1}
required
/>
</label>
<label
style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}
>
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.expiresAt")}
</span>
<input name="expiresAt" type="datetime-local" />
</label>
</div>
<button type="submit" className="btn btn-primary">
{t("form.create")}
</button>
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({vouchers.length})
</h2>
<div className="admin-card">
<table>
<thead>
<tr>
<th>{t("colCode")}</th>
<th>{t("colAmount")}</th>
<th>{t("colUses")}</th>
<th>{t("colStatus")}</th>
<th>{t("colExpires")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{vouchers.map((v) => {
const expired = v.expiresAt
? v.expiresAt.getTime() <= now
: false;
const usedUp = v.maxUses > 0 && v.useCount >= v.maxUses;
return (
<tr key={v.id.toString()}>
<td>
<code>{v.code}</code>
</td>
<td>{v.amount}</td>
<td>
{v.useCount} / {v.maxUses}
</td>
<td>
{expired ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("statusExpired")}
</span>
) : usedUp ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{t("statusUsedUp")}
</span>
) : (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-success-subtle)] text-[var(--admin-text)]">
{t("statusActive")}
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmtDate(v.expiresAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fmtDate(v.createdAt)}
</td>
<td>
<form action={deleteVoucher}>
<input
type="hidden"
name="id"
value={v.id.toString()}
/>
<button type="submit" className="btn btn-danger">
{t("delete")}
</button>
</form>
</td>
</tr>
);
})}
</tbody>
</table>
{vouchers.length === 0 ? (
<div className="admin-empty">{t("noVouchers")}</div>
) : null}
</div>
</section>
</main>
);
}