194 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|