diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 9eff48dd..45cb5210 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -6,8 +6,8 @@ import { TrendingUp, Users, } from "lucide-react"; -import { getLocale, getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; import { DashboardChart } from "@/components/admin/dashboard-chart"; import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap"; import { StatsCard } from "@/components/admin/stats-card"; @@ -46,19 +46,24 @@ const EMPTY: AnalyticsData = { }, }; -async function safeCount(fn: () => Promise): Promise { +async function safeCount( + fn: () => Promise, +): Promise<{ value: number; ok: boolean }> { try { - return await fn(); + return { value: await fn(), ok: true }; } catch { - return 0; + return { value: 0, ok: false }; } } -async function getAnalyticsData(locale: string): Promise { +async function getAnalyticsData( + locale: string, +): Promise<{ data: AnalyticsData; degraded: boolean }> { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; const monthAgo = now - 30 * 86400; + let degraded = false; try { const [ @@ -86,9 +91,9 @@ async function getAnalyticsData(locale: string): Promise { try { const rows = await prisma.$queryRaw<{ count: bigint }[]>` 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 { - return 0; + return { value: 0, ok: false }; } })(), safeCount(() => @@ -99,6 +104,20 @@ async function getAnalyticsData(locale: string): Promise { ), ]); + for (const r of [ + totalUsers, + onlineUsers, + weekRegistrations, + monthRegistrations, + totalRooms, + activeRooms, + weekChatMessages, + weekTrades, + weekBans, + ]) { + if (!r.ok) degraded = true; + } + let registrationChart: { label: string; value: number }[] = []; try { const regRows = await prisma.$queryRaw< @@ -126,6 +145,7 @@ async function getAnalyticsData(locale: string): Promise { } } catch { registrationChart = []; + degraded = true; } const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)); @@ -150,24 +170,27 @@ async function getAnalyticsData(locale: string): Promise { if (count > max) max = count; } } catch { - /* keep empty grid */ + degraded = true; } return { - totalUsers, - onlineUsers, - weekRegistrations, - monthRegistrations, - totalRooms, - activeRooms, - weekChatMessages, - weekTrades, - weekBans, - registrationChart, - peakHours: { grid, max }, + degraded, + data: { + totalUsers: totalUsers.value, + onlineUsers: onlineUsers.value, + weekRegistrations: weekRegistrations.value, + monthRegistrations: monthRegistrations.value, + totalRooms: totalRooms.value, + activeRooms: activeRooms.value, + weekChatMessages: weekChatMessages.value, + weekTrades: weekTrades.value, + weekBans: weekBans.value, + registrationChart, + peakHours: { grid, max }, + }, }; } catch { - return EMPTY; + return { data: EMPTY, degraded: true }; } } @@ -179,10 +202,15 @@ export default async function AnalyticsPage() { const locale = await getLocale(); const t = await getTranslations("pages.admin.analytics"); - const data = await getAnalyticsData(locale); + const { data, degraded } = await getAnalyticsData(locale); return (
+ {degraded ? ( +
+ {t("loadError")} +
+ ) : null}
(); let totalRewards = 0; try { - const rewards = await prisma.calendarRewards.findMany({ - select: { campaignId: true }, + const grouped = await prisma.calendarRewards.groupBy({ + by: ["campaignId"], + _count: { _all: true }, }); - totalRewards = rewards.length; - for (const r of rewards) { - rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1); + for (const g of grouped) { + rewardCounts.set(g.campaignId, g._count._all); + totalRewards += g._count._all; } - } catch {} + } catch { + loadError = true; + } const enabledCount = campaigns.filter((c) => c.enabled === "1").length; return (
+ {loadError ? ( +
+ Could not load some calendar data (database unavailable). +
+ ) : null}
= 4) + const minStaffRank = await getMinStaffRank(); const staff = await prisma.user.findMany({ - where: { rank: { gte: 4 } }, + where: { rank: { gte: minStaffRank } }, select: { id: true, username: true, @@ -77,9 +78,9 @@ export default async function ModTeamPage() {

- {staff.filter((s) => s.rank >= 7).length} + {staff.filter((s) => s.rank > minStaffRank).length}

-

Super Admins

+

Above min rank

diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 0d9ab4ff..2338c5e9 100644 --- a/src/app/admin/settings/cms-settings-config.ts +++ b/src/app/admin/settings/cms-settings-config.ts @@ -1,5 +1,5 @@ -import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; +import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; export type FieldType = | "text" @@ -82,7 +82,8 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [ { key: "min_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", defaultValue: "7", }, diff --git a/src/app/admin/shop/page.tsx b/src/app/admin/shop/page.tsx index 1f2188e0..95a27adb 100644 --- a/src/app/admin/shop/page.tsx +++ b/src/app/admin/shop/page.tsx @@ -1,14 +1,17 @@ import Link from "next/link"; 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 { CurrencyIcon } from "@/components/shared/currency-icon"; +import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; const PAID_STATUS = new Set(["completed", "paid", "success", "approved"]); +const RECENT_ORDERS = 25; function statusState( status: string | null | undefined, @@ -45,36 +48,60 @@ function CurrencyCell({ ); } -export default async function AdminShop() { +export default async function AdminShop({ + searchParams, +}: { + searchParams: Promise>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { redirect("/admin"); } const t = await getTranslations("pages.admin.shop"); + const raw = await searchParams; + const parsed = parseListParams(new URLSearchParams(raw)); + let packageTotal = 0; let packages: Awaited< ReturnType > = []; + let loadError = false; try { + packageTotal = await prisma.websiteShopArticles.count(); + const pagination = calcPagination( + packageTotal, + parsed.page, + parsed.perPage, + ); packages = await prisma.websiteShopArticles.findMany({ orderBy: [{ position: "asc" }, { name: "asc" }], - take: 500, + skip: pagination.offset, + take: pagination.perPage, }); } catch { packages = []; + packageTotal = 0; + loadError = true; } + const pkgPagination = calcPagination( + packageTotal, + parsed.page, + parsed.perPage, + ); + let orders: Awaited< ReturnType > = []; try { orders = await prisma.websitePaypalTransactions.findMany({ orderBy: { createdAt: "desc" }, - take: 50, + take: RECENT_ORDERS, }); } catch { orders = []; + loadError = true; } const paidOrders = orders.filter( @@ -83,6 +110,11 @@ export default async function AdminShop() { return (
+ {loadError ? ( +
+ Could not load some shop data (database unavailable). +
+ ) : null}
{t("newPackage")} @@ -90,7 +122,7 @@ export default async function AdminShop() {
- +

- {t("packages")} ({packages.length}) + {t("packagesTotal", { count: packageTotal })}

{packages.length === 0 ? (
{t("noPackages")}
@@ -146,6 +178,12 @@ export default async function AdminShop() {
)} +
diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index 69bc645e..3e194bac 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -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 { 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 { prisma } from "@/lib/prisma"; type SubRow = { id: number; @@ -15,7 +17,11 @@ function fmt(ts: number): string { return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); } -export default async function AdminSubscriptions() { +export default async function AdminSubscriptions({ + searchParams, +}: { + searchParams: Promise>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { redirect("/admin"); @@ -23,36 +29,86 @@ export default async function AdminSubscriptions() { const t = await getTranslations("pages.admin.subscriptions"); const now = Math.floor(Date.now() / 1000); + const raw = await searchParams; + const parsed = parseListParams(new URLSearchParams(raw)); let active: SubRow[] = []; + let total = 0; + let types = 0; + let expiringSoon = 0; + let loadError = false; try { - const rows = await prisma.usersSubscriptions.findMany({ - where: { active: 1 }, - orderBy: { id: "desc" }, - take: 500, - }); + const [countRows, typeRows, soonRows] = await Promise.all([ + prisma.$queryRaw<{ c: bigint }[]>` + SELECT COUNT(*) AS c + 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 - .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); + const rows = await prisma.$queryRaw< + { + id: number; + user_id: number | null; + subscription_type: string | null; + expires_at: number; + }[] + >` + 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 { 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 expiringSoon = active.filter((s) => s.expiresAt <= soonCutoff).length; - const types = new Set(active.map((s) => s.subscriptionType ?? "—")).size; return (
+ {loadError ? ( +
+ Could not load subscriptions (database unavailable). +
+ ) : null}
- +

- {t("activeSubscriptions")} ({active.length}) + {t("activeSubscriptions")} ({total})

@@ -78,34 +134,34 @@ export default async function AdminSubscriptions() { - {active.map((s) => { - const soon = s.expiresAt <= soonCutoff; - return ( + {active.length === 0 ? ( + + + + ) : ( + active.map((s) => ( - + - + - ); - })} + )) + )}
+ — +
- {s.id} - {s.id} {s.userId ?? "—"} {s.subscriptionType ?? "—"} - - {soon ? t("statusExpiring") : t("statusActive")} - - - {fmt(s.expiresAt)} + {s.expiresAt <= soonCutoff ? t("expiringSoon") : "OK"} {fmt(s.expiresAt)}
- {active.length === 0 ? ( -
{t("noSubscriptions")}
- ) : null}
+
); diff --git a/src/app/admin/tickets/[id]/page.tsx b/src/app/admin/tickets/[id]/page.tsx index 22f6bdcd..5116be91 100644 --- a/src/app/admin/tickets/[id]/page.tsx +++ b/src/app/admin/tickets/[id]/page.tsx @@ -1,4 +1,5 @@ import { notFound, redirect } from "next/navigation"; +import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { AdminTicketDetail } from "./admin-ticket-detail"; @@ -45,9 +46,10 @@ export default async function AdminTicketDetailPage({ 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({ - where: { rank: { gte: 4 } }, + where: { rank: { gte: minStaffRank } }, select: { id: true, username: true }, orderBy: { username: "asc" }, }); diff --git a/src/components/admin/admin-simple-pager.tsx b/src/components/admin/admin-simple-pager.tsx new file mode 100644 index 00000000..66568486 --- /dev/null +++ b/src/components/admin/admin-simple-pager.tsx @@ -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 ( +
+ + {label ? `${label} · ` : null} + {total} total · page {page}/{lastPage} + +
+ {page > 1 ? ( + + Previous + + ) : ( + Previous + )} + {page < lastPage ? ( + + Next + + ) : ( + Next + )} +
+
+ ); +} diff --git a/src/lib/admin/min-staff-rank.ts b/src/lib/admin/min-staff-rank.ts new file mode 100644 index 00000000..6ef33421 --- /dev/null +++ b/src/lib/admin/min-staff-rank.ts @@ -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 { + const raw = await siteSettings.get("min_staff_rank", String(fallback)); + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} diff --git a/src/messages/en.json b/src/messages/en.json index 2316df3e..26a4b640 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1018,8 +1018,8 @@ "advertisements": "Advertisements", "writeableBoxes": "Writeable boxes", "photos": "Photos", - "tickets": "Tickets", - "helpTickets": "Help center tickets", + "tickets": "Support desk", + "helpTickets": "Help-center tickets", "events": "Events", "polls": "Polls", "banners": "Banners", @@ -1735,7 +1735,9 @@ "orders": "Orders", "recentOrders": "Recent 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", "edit": "Edit", "delete": "Delete", @@ -2448,8 +2450,8 @@ "events": "Events", "eventTypes": "Event types", "polls": "Polls", - "tickets": "Tickets", - "helpTickets": "Help center tickets", + "tickets": "CMS tickets", + "helpTickets": "Help-center tickets", "templates": "Templates", "directory": "Directory", "multiAccounts": "Multi-accounts", @@ -2510,8 +2512,8 @@ }, "events": "Events", "eventsSubtitle": "Hotel events and event types", - "tickets": "Tickets", - "ticketsSubtitle": "Support tickets and reply templates", + "tickets": "Support desk", + "ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates", "staffAccess": "Ranks & permissions", "staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access", "analytics": "Analytics", @@ -2889,6 +2891,7 @@ } }, "analytics": { + "loadError": "Some analytics queries failed. Numbers below may be incomplete — check the database connection and emulator tables.", "totalUsers": "Total users", "onlineNow": "{count} online now", "weekRegistrations": "Week registrations", diff --git a/src/messages/it.json b/src/messages/it.json index f7977581..66332b44 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -952,7 +952,7 @@ "advertisements": "Pubblicità", "writeableBoxes": "Box modificabili", "photos": "Foto", - "tickets": "Ticket", + "tickets": "Assistenza", "helpTickets": "Ticket centro assistenza", "events": "Eventi", "polls": "Sondaggi", @@ -1673,7 +1673,9 @@ "orders": "Ordini", "recentOrders": "Ordini recenti", "paidOrders": "Ordini pagati", - "ordersHint": "Ultime transazioni PayPal", + "ordersHint": "Ultime transazioni PayPal (ultime 25)", + "pageOf": "Pagina {page} di {lastPage}", + "packagesTotal": "{count} pacchetti", "newPackage": "+ Nuovo pacchetto", "edit": "Modifica", "delete": "Elimina", @@ -2665,7 +2667,7 @@ "events": "Eventi", "eventTypes": "Tipi evento", "polls": "Sondaggi", - "tickets": "Ticket", + "tickets": "Ticket CMS", "helpTickets": "Ticket centro assistenza", "templates": "Template", "directory": "Directory", @@ -2727,8 +2729,8 @@ }, "events": "Eventi", "eventsSubtitle": "Eventi hotel e tipi evento", - "tickets": "Ticket", - "ticketsSubtitle": "Ticket di supporto e template risposte", + "tickets": "Assistenza", + "ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte", "staffAccess": "Gradi e permessi", "staffAccessSubtitle": "Gradi staff, permessi ACL e accesso housekeeping", "analytics": "Analitiche", @@ -2829,6 +2831,7 @@ } }, "analytics": { + "loadError": "Alcune query analytics sono fallite. I numeri sotto possono essere incompleti — controlla DB e tabelle emulator.", "totalUsers": "Utenti totali", "onlineNow": "{count} online ora", "weekRegistrations": "Registrazioni della settimana", diff --git a/src/messages/nl.json b/src/messages/nl.json index 70b28658..c6114850 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -997,7 +997,7 @@ "advertisements": "Advertenties", "writeableBoxes": "Bewerkbare boxes", "photos": "Foto's", - "tickets": "Tickets", + "tickets": "Supportbalie", "helpTickets": "Helpcentrum-tickets", "events": "Events", "polls": "Polls", @@ -1649,7 +1649,9 @@ "orders": "Bestellingen", "recentOrders": "Recente 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", "edit": "Bewerken", "delete": "Verwijderen", @@ -2731,7 +2733,7 @@ "events": "Evenementen", "eventTypes": "Evenementtypen", "polls": "Polls", - "tickets": "Tickets", + "tickets": "CMS-tickets", "helpTickets": "Helpcentrum-tickets", "templates": "Sjablonen", "directory": "Adresboek", @@ -2793,8 +2795,8 @@ }, "events": "Evenementen", "eventsSubtitle": "Hotel-evenementen en evenementtypes", - "tickets": "Tickets", - "ticketsSubtitle": "Supporttickets en antwoordtemplates", + "tickets": "Supportbalie", + "ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates", "staffAccess": "Rangen & rechten", "staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang", "analytics": "Analytics", @@ -2872,6 +2874,7 @@ "colStatus": "Status" }, "analytics": { + "loadError": "Sommige analytics-queries zijn mislukt. Onderstaande cijfers kunnen onvolledig zijn — controleer de database en emulator-tabellen.", "totalUsers": "Totaal gebruikers", "onlineNow": "{count} nu online", "weekRegistrations": "Registraties deze week",