feat(admin): P1 — clearer tickets labels, pagination, min_staff_rank, analytics errors
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m37s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 19:04:45 +02:00
1 parent 75cdfdebe4
commit f150aea8b9
13 files changed
+311 -101

No files matched your search

+49 -21
View File
@@ -6,8 +6,8 @@ import {
TrendingUp, TrendingUp,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { getLocale, getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { DashboardChart } from "@/components/admin/dashboard-chart"; import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap"; import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
@@ -46,19 +46,24 @@ const EMPTY: AnalyticsData = {
}, },
}; };
async function safeCount(fn: () => Promise<number>): Promise<number> { async function safeCount(
fn: () => Promise<number>,
): Promise<{ value: number; ok: boolean }> {
try { try {
return await fn(); return { value: await fn(), ok: true };
} catch { } catch {
return 0; return { value: 0, ok: false };
} }
} }
async function getAnalyticsData(locale: string): Promise<AnalyticsData> { async function getAnalyticsData(
locale: string,
): Promise<{ data: AnalyticsData; degraded: boolean }> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400); const todayStart = now - (now % 86400);
const weekAgo = now - 7 * 86400; const weekAgo = now - 7 * 86400;
const monthAgo = now - 30 * 86400; const monthAgo = now - 30 * 86400;
let degraded = false;
try { try {
const [ const [
@@ -86,9 +91,9 @@ async function getAnalyticsData(locale: string): Promise<AnalyticsData> {
try { try {
const rows = await prisma.$queryRaw<{ count: bigint }[]>` const rows = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`; SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`;
return Number(rows[0]?.count ?? 0); return { value: Number(rows[0]?.count ?? 0), ok: true };
} catch { } catch {
return 0; return { value: 0, ok: false };
} }
})(), })(),
safeCount(() => safeCount(() =>
@@ -99,6 +104,20 @@ async function getAnalyticsData(locale: string): Promise<AnalyticsData> {
), ),
]); ]);
for (const r of [
totalUsers,
onlineUsers,
weekRegistrations,
monthRegistrations,
totalRooms,
activeRooms,
weekChatMessages,
weekTrades,
weekBans,
]) {
if (!r.ok) degraded = true;
}
let registrationChart: { label: string; value: number }[] = []; let registrationChart: { label: string; value: number }[] = [];
try { try {
const regRows = await prisma.$queryRaw< const regRows = await prisma.$queryRaw<
@@ -126,6 +145,7 @@ async function getAnalyticsData(locale: string): Promise<AnalyticsData> {
} }
} catch { } catch {
registrationChart = []; registrationChart = [];
degraded = true;
} }
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)); const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
@@ -150,24 +170,27 @@ async function getAnalyticsData(locale: string): Promise<AnalyticsData> {
if (count > max) max = count; if (count > max) max = count;
} }
} catch { } catch {
/* keep empty grid */ degraded = true;
} }
return { return {
totalUsers, degraded,
onlineUsers, data: {
weekRegistrations, totalUsers: totalUsers.value,
monthRegistrations, onlineUsers: onlineUsers.value,
totalRooms, weekRegistrations: weekRegistrations.value,
activeRooms, monthRegistrations: monthRegistrations.value,
weekChatMessages, totalRooms: totalRooms.value,
weekTrades, activeRooms: activeRooms.value,
weekBans, weekChatMessages: weekChatMessages.value,
registrationChart, weekTrades: weekTrades.value,
peakHours: { grid, max }, weekBans: weekBans.value,
registrationChart,
peakHours: { grid, max },
},
}; };
} catch { } catch {
return EMPTY; return { data: EMPTY, degraded: true };
} }
} }
@@ -179,10 +202,15 @@ export default async function AnalyticsPage() {
const locale = await getLocale(); const locale = await getLocale();
const t = await getTranslations("pages.admin.analytics"); const t = await getTranslations("pages.admin.analytics");
const data = await getAnalyticsData(locale); const { data, degraded } = await getAnalyticsData(locale);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{degraded ? (
<div className="rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
{t("loadError")}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard <StatsCard
title={t("totalUsers")} title={t("totalUsers")}
+19 -8
View File
@@ -1,9 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -31,6 +31,7 @@ export default async function AdminCalendar() {
const t = await getTranslations("pages.admin.calendar"); const t = await getTranslations("pages.admin.calendar");
let campaigns: Campaign[] = []; let campaigns: Campaign[] = [];
let loadError = false;
try { try {
campaigns = await prisma.calendarCampaigns.findMany({ campaigns = await prisma.calendarCampaigns.findMany({
select: { select: {
@@ -43,27 +44,37 @@ export default async function AdminCalendar() {
enabled: true, enabled: true,
}, },
orderBy: { id: "desc" }, orderBy: { id: "desc" },
take: 200,
}); });
} catch { } catch {
campaigns = []; campaigns = [];
loadError = true;
} }
const rewardCounts = new Map<number, number>(); const rewardCounts = new Map<number, number>();
let totalRewards = 0; let totalRewards = 0;
try { try {
const rewards = await prisma.calendarRewards.findMany({ const grouped = await prisma.calendarRewards.groupBy({
select: { campaignId: true }, by: ["campaignId"],
_count: { _all: true },
}); });
totalRewards = rewards.length; for (const g of grouped) {
for (const r of rewards) { rewardCounts.set(g.campaignId, g._count._all);
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1); totalRewards += g._count._all;
} }
} catch {} } catch {
loadError = true;
}
const enabledCount = campaigns.filter((c) => c.enabled === "1").length; const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
return ( return (
<main> <main>
{loadError ? (
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
Could not load some calendar data (database unavailable).
</div>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" /> <StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
<StatusCard <StatusCard
+5 -1
View File
@@ -12,6 +12,7 @@ import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -34,6 +35,7 @@ export default async function ModerationDashboard() {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400); const todayStart = now - (now % 86400);
const minStaffRank = await getMinStaffRank();
const [ const [
openCfh, openCfh,
@@ -46,7 +48,9 @@ export default async function ModerationDashboard() {
prisma.supportTickets.count({ where: { state: 0 } }), prisma.supportTickets.count({ where: { state: 0 } }),
prisma.supportTickets.count({ where: { state: 1 } }), prisma.supportTickets.count({ where: { state: 1 } }),
prisma.ban.count({ where: { timestamp: { gte: todayStart } } }), prisma.ban.count({ where: { timestamp: { gte: todayStart } } }),
prisma.user.count({ where: { online: "1", rank: { gte: 4 } } }), prisma.user.count({
where: { online: "1", rank: { gte: minStaffRank } },
}),
prisma.supportTickets.findMany({ prisma.supportTickets.findMany({
orderBy: { id: "desc" }, orderBy: { id: "desc" },
take: 10, take: 10,
+5 -4
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { AvatarImage } from "@/components/shared/avatar-image"; import { AvatarImage } from "@/components/shared/avatar-image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -11,9 +12,9 @@ export default async function ModTeamPage() {
if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank))
redirect("/admin"); redirect("/admin");
// Get all staff (rank >= 4) const minStaffRank = await getMinStaffRank();
const staff = await prisma.user.findMany({ const staff = await prisma.user.findMany({
where: { rank: { gte: 4 } }, where: { rank: { gte: minStaffRank } },
select: { select: {
id: true, id: true,
username: true, username: true,
@@ -77,9 +78,9 @@ export default async function ModTeamPage() {
<Card> <Card>
<CardContent className="pt-4 text-center"> <CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-primary"> <p className="text-2xl font-bold text-primary">
{staff.filter((s) => s.rank >= 7).length} {staff.filter((s) => s.rank > minStaffRank).length}
</p> </p>
<p className="text-xs text-muted-foreground">Super Admins</p> <p className="text-xs text-muted-foreground">Above min rank</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -1,5 +1,5 @@
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
export type FieldType = export type FieldType =
| "text" | "text"
@@ -82,7 +82,8 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
{ {
key: "min_staff_rank", key: "min_staff_rank",
label: "Minimum staff rank", label: "Minimum staff rank",
description: "Rank shown on the public staff page.", description:
"Minimum rank treated as staff (admin lists and public staff page).",
type: "number", type: "number",
defaultValue: "7", defaultValue: "7",
}, },
+43 -5
View File
@@ -1,14 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { CurrencyIcon } from "@/components/shared/currency-icon"; import { CurrencyIcon } from "@/components/shared/currency-icon";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]); const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]);
const RECENT_ORDERS = 25;
function statusState( function statusState(
status: string | null | undefined, status: string | null | undefined,
@@ -45,36 +48,60 @@ function CurrencyCell({
); );
} }
export default async function AdminShop() { export default async function AdminShop({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("pages.admin.shop"); const t = await getTranslations("pages.admin.shop");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
let packageTotal = 0;
let packages: Awaited< let packages: Awaited<
ReturnType<typeof prisma.websiteShopArticles.findMany> ReturnType<typeof prisma.websiteShopArticles.findMany>
> = []; > = [];
let loadError = false;
try { try {
packageTotal = await prisma.websiteShopArticles.count();
const pagination = calcPagination(
packageTotal,
parsed.page,
parsed.perPage,
);
packages = await prisma.websiteShopArticles.findMany({ packages = await prisma.websiteShopArticles.findMany({
orderBy: [{ position: "asc" }, { name: "asc" }], orderBy: [{ position: "asc" }, { name: "asc" }],
take: 500, skip: pagination.offset,
take: pagination.perPage,
}); });
} catch { } catch {
packages = []; packages = [];
packageTotal = 0;
loadError = true;
} }
const pkgPagination = calcPagination(
packageTotal,
parsed.page,
parsed.perPage,
);
let orders: Awaited< let orders: Awaited<
ReturnType<typeof prisma.websitePaypalTransactions.findMany> ReturnType<typeof prisma.websitePaypalTransactions.findMany>
> = []; > = [];
try { try {
orders = await prisma.websitePaypalTransactions.findMany({ orders = await prisma.websitePaypalTransactions.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 50, take: RECENT_ORDERS,
}); });
} catch { } catch {
orders = []; orders = [];
loadError = true;
} }
const paidOrders = orders.filter( const paidOrders = orders.filter(
@@ -83,6 +110,11 @@ export default async function AdminShop() {
return ( return (
<main> <main>
{loadError ? (
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
Could not load some shop data (database unavailable).
</div>
) : null}
<div className="flex items-center justify-end gap-4"> <div className="flex items-center justify-end gap-4">
<Link href="/admin/shop/new" className="btn btn-primary"> <Link href="/admin/shop/new" className="btn btn-primary">
{t("newPackage")} {t("newPackage")}
@@ -90,7 +122,7 @@ export default async function AdminShop() {
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("packages")} value={packages.length} icon="🛍️" /> <StatusCard label={t("packages")} value={packageTotal} icon="🛍️" />
<StatusCard label={t("recentOrders")} value={orders.length} icon="🧾" /> <StatusCard label={t("recentOrders")} value={orders.length} icon="🧾" />
<StatusCard <StatusCard
label={t("paidOrders")} label={t("paidOrders")}
@@ -102,7 +134,7 @@ export default async function AdminShop() {
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title"> <h2 className="admin-section-title">
{t("packages")} ({packages.length}) {t("packagesTotal", { count: packageTotal })}
</h2> </h2>
{packages.length === 0 ? ( {packages.length === 0 ? (
<div className="admin-empty">{t("noPackages")}</div> <div className="admin-empty">{t("noPackages")}</div>
@@ -146,6 +178,12 @@ export default async function AdminShop() {
</table> </table>
</div> </div>
)} )}
<AdminSimplePager
page={pkgPagination.page}
lastPage={pkgPagination.lastPage}
total={packageTotal}
basePath="/admin/shop"
/>
</section> </section>
<section className="mt-6"> <section className="mt-6">
+97 -41
View File
@@ -1,8 +1,10 @@
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
import { StatusCard } from "@/components/admin/dashboard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
type SubRow = { type SubRow = {
id: number; id: number;
@@ -15,7 +17,11 @@ function fmt(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
} }
export default async function AdminSubscriptions() { export default async function AdminSubscriptions({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
redirect("/admin"); redirect("/admin");
@@ -23,36 +29,86 @@ export default async function AdminSubscriptions() {
const t = await getTranslations("pages.admin.subscriptions"); const t = await getTranslations("pages.admin.subscriptions");
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
let active: SubRow[] = []; let active: SubRow[] = [];
let total = 0;
let types = 0;
let expiringSoon = 0;
let loadError = false;
try { try {
const rows = await prisma.usersSubscriptions.findMany({ const [countRows, typeRows, soonRows] = await Promise.all([
where: { active: 1 }, prisma.$queryRaw<{ c: bigint }[]>`
orderBy: { id: "desc" }, SELECT COUNT(*) AS c
take: 500, FROM users_subscriptions
}); WHERE active = 1
AND COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > ${now}
`,
prisma.$queryRaw<{ c: bigint }[]>`
SELECT COUNT(DISTINCT subscription_type) AS c
FROM users_subscriptions
WHERE active = 1
AND COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > ${now}
`,
prisma.$queryRaw<{ c: bigint }[]>`
SELECT COUNT(*) AS c
FROM users_subscriptions
WHERE active = 1
AND COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > ${now}
AND COALESCE(timestamp_start, 0) + COALESCE(duration, 0) <= ${now + 7 * 24 * 60 * 60}
`,
]);
total = Number(countRows[0]?.c ?? 0);
types = Number(typeRows[0]?.c ?? 0);
expiringSoon = Number(soonRows[0]?.c ?? 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
active = rows const rows = await prisma.$queryRaw<
.map((r) => ({ {
id: r.id, id: number;
userId: r.userId, user_id: number | null;
subscriptionType: r.subscriptionType, subscription_type: string | null;
expiresAt: (r.timestampStart ?? 0) + (r.duration ?? 0), expires_at: number;
})) }[]
.filter((r) => r.expiresAt > now) >`
.sort((a, b) => a.expiresAt - b.expiresAt); SELECT id,
user_id,
subscription_type,
COALESCE(timestamp_start, 0) + COALESCE(duration, 0) AS expires_at
FROM users_subscriptions
WHERE active = 1
AND COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > ${now}
ORDER BY expires_at ASC
LIMIT ${pagination.perPage} OFFSET ${pagination.offset}
`;
active = rows.map((r) => ({
id: Number(r.id),
userId: r.user_id == null ? null : Number(r.user_id),
subscriptionType: r.subscription_type,
expiresAt: Number(r.expires_at),
}));
} catch { } catch {
active = []; active = [];
total = 0;
types = 0;
expiringSoon = 0;
loadError = true;
} }
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const soonCutoff = now + 7 * 24 * 60 * 60; 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 ( return (
<main> <main>
{loadError ? (
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
Could not load subscriptions (database unavailable).
</div>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <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("activeSubs")} value={total} icon="⭐" />
<StatusCard label={t("types")} value={types} icon="🏷️" /> <StatusCard label={t("types")} value={types} icon="🏷️" />
<StatusCard <StatusCard
label={t("expiringSoon")} label={t("expiringSoon")}
@@ -64,7 +120,7 @@ export default async function AdminSubscriptions() {
<section className="mt-6"> <section className="mt-6">
<h2 className="admin-section-title"> <h2 className="admin-section-title">
{t("activeSubscriptions")} ({active.length}) {t("activeSubscriptions")} ({total})
</h2> </h2>
<div className="admin-card"> <div className="admin-card">
<table> <table>
@@ -78,34 +134,34 @@ export default async function AdminSubscriptions() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{active.map((s) => { {active.length === 0 ? (
const soon = s.expiresAt <= soonCutoff; <tr>
return ( <td colSpan={5} className="muted">
—
</td>
</tr>
) : (
active.map((s) => (
<tr key={s.id}> <tr key={s.id}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap"> <td>{s.id}</td>
{s.id}
</td>
<td>{s.userId ?? "—"}</td> <td>{s.userId ?? "—"}</td>
<td>{s.subscriptionType ?? "—"}</td> <td>{s.subscriptionType ?? "—"}</td>
<td> <td>
<span {s.expiresAt <= soonCutoff ? t("expiringSoon") : "OK"}
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> </td>
<td>{fmt(s.expiresAt)}</td>
</tr> </tr>
); ))
})} )}
</tbody> </tbody>
</table> </table>
{active.length === 0 ? (
<div className="admin-empty">{t("noSubscriptions")}</div>
) : null}
</div> </div>
<AdminSimplePager
page={pagination.page}
lastPage={pagination.lastPage}
total={total}
basePath="/admin/subscriptions"
/>
</section> </section>
</main> </main>
); );
+4 -2
View File
@@ -1,4 +1,5 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { AdminTicketDetail } from "./admin-ticket-detail"; import { AdminTicketDetail } from "./admin-ticket-detail";
@@ -45,9 +46,10 @@ export default async function AdminTicketDetailPage({
if (!ticket) notFound(); if (!ticket) notFound();
// Get staff members for assignment // Staff assignees: min_staff_rank and above
const minStaffRank = await getMinStaffRank();
const staffMembers = await prisma.user.findMany({ const staffMembers = await prisma.user.findMany({
where: { rank: { gte: 4 } }, where: { rank: { gte: minStaffRank } },
select: { id: true, username: true }, select: { id: true, username: true },
orderBy: { username: "asc" }, orderBy: { username: "asc" },
}); });
@@ -0,0 +1,49 @@
import Link from "next/link";
/** Minimal prev/next pager for admin list pages without DataTable. */
export function AdminSimplePager({
page,
lastPage,
total,
basePath,
label,
}: {
page: number;
lastPage: number;
total: number;
basePath: string;
label?: string;
}) {
if (lastPage <= 1) return null;
const href = (p: number) => {
const u = new URLSearchParams();
u.set("page", String(p));
return `${basePath}?${u.toString()}`;
};
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-sm text-[var(--admin-text-muted)]">
<span>
{label ? `${label} · ` : null}
{total} total · page {page}/{lastPage}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link className="btn btn-ghost btn-sm" href={href(page - 1)}>
Previous
</Link>
) : (
<span className="opacity-40">Previous</span>
)}
{page < lastPage ? (
<Link className="btn btn-ghost btn-sm" href={href(page + 1)}>
Next
</Link>
) : (
<span className="opacity-40">Next</span>
)}
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { siteSettings } from "@/lib/services/site-settings";
/**
* Minimum hotel rank treated as staff (admin lists + public staff page).
* Setting: `min_staff_rank` (default 7).
*/
export async function getMinStaffRank(fallback = 7): Promise<number> {
const raw = await siteSettings.get("min_staff_rank", String(fallback));
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
+10 -7
View File
@@ -1018,8 +1018,8 @@
"advertisements": "Advertisements", "advertisements": "Advertisements",
"writeableBoxes": "Writeable boxes", "writeableBoxes": "Writeable boxes",
"photos": "Photos", "photos": "Photos",
"tickets": "Tickets", "tickets": "Support desk",
"helpTickets": "Help center tickets", "helpTickets": "Help-center tickets",
"events": "Events", "events": "Events",
"polls": "Polls", "polls": "Polls",
"banners": "Banners", "banners": "Banners",
@@ -1735,7 +1735,9 @@
"orders": "Orders", "orders": "Orders",
"recentOrders": "Recent orders", "recentOrders": "Recent orders",
"paidOrders": "Paid orders", "paidOrders": "Paid orders",
"ordersHint": "Latest PayPal transactions", "ordersHint": "Latest PayPal transactions (most recent 25)",
"pageOf": "Page {page} of {lastPage}",
"packagesTotal": "{count} packages",
"newPackage": "+ New package", "newPackage": "+ New package",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
@@ -2448,8 +2450,8 @@
"events": "Events", "events": "Events",
"eventTypes": "Event types", "eventTypes": "Event types",
"polls": "Polls", "polls": "Polls",
"tickets": "Tickets", "tickets": "CMS tickets",
"helpTickets": "Help center tickets", "helpTickets": "Help-center tickets",
"templates": "Templates", "templates": "Templates",
"directory": "Directory", "directory": "Directory",
"multiAccounts": "Multi-accounts", "multiAccounts": "Multi-accounts",
@@ -2510,8 +2512,8 @@
}, },
"events": "Events", "events": "Events",
"eventsSubtitle": "Hotel events and event types", "eventsSubtitle": "Hotel events and event types",
"tickets": "Tickets", "tickets": "Support desk",
"ticketsSubtitle": "Support tickets and reply templates", "ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates",
"staffAccess": "Ranks & permissions", "staffAccess": "Ranks & permissions",
"staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access", "staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access",
"analytics": "Analytics", "analytics": "Analytics",
@@ -2889,6 +2891,7 @@
} }
}, },
"analytics": { "analytics": {
"loadError": "Some analytics queries failed. Numbers below may be incomplete — check the database connection and emulator tables.",
"totalUsers": "Total users", "totalUsers": "Total users",
"onlineNow": "{count} online now", "onlineNow": "{count} online now",
"weekRegistrations": "Week registrations", "weekRegistrations": "Week registrations",
+8 -5
View File
@@ -952,7 +952,7 @@
"advertisements": "Pubblicità", "advertisements": "Pubblicità",
"writeableBoxes": "Box modificabili", "writeableBoxes": "Box modificabili",
"photos": "Foto", "photos": "Foto",
"tickets": "Ticket", "tickets": "Assistenza",
"helpTickets": "Ticket centro assistenza", "helpTickets": "Ticket centro assistenza",
"events": "Eventi", "events": "Eventi",
"polls": "Sondaggi", "polls": "Sondaggi",
@@ -1673,7 +1673,9 @@
"orders": "Ordini", "orders": "Ordini",
"recentOrders": "Ordini recenti", "recentOrders": "Ordini recenti",
"paidOrders": "Ordini pagati", "paidOrders": "Ordini pagati",
"ordersHint": "Ultime transazioni PayPal", "ordersHint": "Ultime transazioni PayPal (ultime 25)",
"pageOf": "Pagina {page} di {lastPage}",
"packagesTotal": "{count} pacchetti",
"newPackage": "+ Nuovo pacchetto", "newPackage": "+ Nuovo pacchetto",
"edit": "Modifica", "edit": "Modifica",
"delete": "Elimina", "delete": "Elimina",
@@ -2665,7 +2667,7 @@
"events": "Eventi", "events": "Eventi",
"eventTypes": "Tipi evento", "eventTypes": "Tipi evento",
"polls": "Sondaggi", "polls": "Sondaggi",
"tickets": "Ticket", "tickets": "Ticket CMS",
"helpTickets": "Ticket centro assistenza", "helpTickets": "Ticket centro assistenza",
"templates": "Template", "templates": "Template",
"directory": "Directory", "directory": "Directory",
@@ -2727,8 +2729,8 @@
}, },
"events": "Eventi", "events": "Eventi",
"eventsSubtitle": "Eventi hotel e tipi evento", "eventsSubtitle": "Eventi hotel e tipi evento",
"tickets": "Ticket", "tickets": "Assistenza",
"ticketsSubtitle": "Ticket di supporto e template risposte", "ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte",
"staffAccess": "Gradi e permessi", "staffAccess": "Gradi e permessi",
"staffAccessSubtitle": "Gradi staff, permessi ACL e accesso housekeeping", "staffAccessSubtitle": "Gradi staff, permessi ACL e accesso housekeeping",
"analytics": "Analitiche", "analytics": "Analitiche",
@@ -2829,6 +2831,7 @@
} }
}, },
"analytics": { "analytics": {
"loadError": "Alcune query analytics sono fallite. I numeri sotto possono essere incompleti — controlla DB e tabelle emulator.",
"totalUsers": "Utenti totali", "totalUsers": "Utenti totali",
"onlineNow": "{count} online ora", "onlineNow": "{count} online ora",
"weekRegistrations": "Registrazioni della settimana", "weekRegistrations": "Registrazioni della settimana",
+8 -5
View File
@@ -997,7 +997,7 @@
"advertisements": "Advertenties", "advertisements": "Advertenties",
"writeableBoxes": "Bewerkbare boxes", "writeableBoxes": "Bewerkbare boxes",
"photos": "Foto's", "photos": "Foto's",
"tickets": "Tickets", "tickets": "Supportbalie",
"helpTickets": "Helpcentrum-tickets", "helpTickets": "Helpcentrum-tickets",
"events": "Events", "events": "Events",
"polls": "Polls", "polls": "Polls",
@@ -1649,7 +1649,9 @@
"orders": "Bestellingen", "orders": "Bestellingen",
"recentOrders": "Recente bestellingen", "recentOrders": "Recente bestellingen",
"paidOrders": "Betaalde bestellingen", "paidOrders": "Betaalde bestellingen",
"ordersHint": "Laatste PayPal-transacties", "ordersHint": "Laatste PayPal-transacties (meest recente 25)",
"pageOf": "Pagina {page} van {lastPage}",
"packagesTotal": "{count} pakketten",
"newPackage": "+ Nieuw pakket", "newPackage": "+ Nieuw pakket",
"edit": "Bewerken", "edit": "Bewerken",
"delete": "Verwijderen", "delete": "Verwijderen",
@@ -2731,7 +2733,7 @@
"events": "Evenementen", "events": "Evenementen",
"eventTypes": "Evenementtypen", "eventTypes": "Evenementtypen",
"polls": "Polls", "polls": "Polls",
"tickets": "Tickets", "tickets": "CMS-tickets",
"helpTickets": "Helpcentrum-tickets", "helpTickets": "Helpcentrum-tickets",
"templates": "Sjablonen", "templates": "Sjablonen",
"directory": "Adresboek", "directory": "Adresboek",
@@ -2793,8 +2795,8 @@
}, },
"events": "Evenementen", "events": "Evenementen",
"eventsSubtitle": "Hotel-evenementen en evenementtypes", "eventsSubtitle": "Hotel-evenementen en evenementtypes",
"tickets": "Tickets", "tickets": "Supportbalie",
"ticketsSubtitle": "Supporttickets en antwoordtemplates", "ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
"staffAccess": "Rangen & rechten", "staffAccess": "Rangen & rechten",
"staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang", "staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang",
"analytics": "Analytics", "analytics": "Analytics",
@@ -2872,6 +2874,7 @@
"colStatus": "Status" "colStatus": "Status"
}, },
"analytics": { "analytics": {
"loadError": "Sommige analytics-queries zijn mislukt. Onderstaande cijfers kunnen onvolledig zijn — controleer de database en emulator-tabellen.",
"totalUsers": "Totaal gebruikers", "totalUsers": "Totaal gebruikers",
"onlineNow": "{count} nu online", "onlineNow": "{count} nu online",
"weekRegistrations": "Registraties deze week", "weekRegistrations": "Registraties deze week",