Files
Epicnabbo-Catalogus-Updated…/src/app/admin/logs/page.tsx
T

194 lines
6.9 KiB
TypeScript

import { getTranslations } from "next-intl/server";
import { ScrollText } from "lucide-react";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminStaffActivities({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string; authorization?: string }>;
}) {
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const whereClause = buildStaffActivityWhere({ q, staffId, action, authorizationOnly });
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
select: {
id: true,
userId: true,
action: true,
description: true,
targetType: true,
targetId: true,
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
})
.catch(() => []);
const staffIds = Array.from(new Set(activities.map((a) => Number(a.userId)))).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({ where: { id: { in: staffIds } }, select: { id: true, username: true } })
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities.count({ where: whereClause }).catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
).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(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
<ScrollText size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-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("totalActivities")} value={total} icon="📋" />
<StatusCard label={t("onThisPage")} value={activities.length} icon="📄" />
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="authorization" value="1" defaultChecked={authorizationOnly} />
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">{t("activities", { count: total })}</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#{String(a.targetId)}
</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">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
);
}