- Add error display to help questions new/edit pages - Improve error visibility in admin-badges, admin-applications, admin-logs, admin-users pages - Update createHelpQuestion action to properly handle and display unique name collisions and database errors - Add user-friendly error messages to admin error handling - Enhances admin page error reporting for better user experience
188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import { StatusCard } from "@/components/admin/dashboard";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
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 }>;
|
|
}) {
|
|
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;
|
|
|
|
let whereClause = {};
|
|
if (q) {
|
|
whereClause = {
|
|
OR: [
|
|
{ action: { contains: q } },
|
|
{ description: { contains: q } },
|
|
{ ipAddress: { contains: q } },
|
|
],
|
|
};
|
|
}
|
|
|
|
if (staffId) {
|
|
whereClause = {
|
|
...whereClause,
|
|
userId: BigInt(staffId),
|
|
};
|
|
}
|
|
|
|
if (action) {
|
|
whereClause = {
|
|
...whereClause,
|
|
action: { contains: action },
|
|
};
|
|
}
|
|
|
|
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>
|
|
<h1>Staff Activities</h1>
|
|
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
|
Auditor trail of all staff actions in the hotel.
|
|
</p>
|
|
|
|
<div className="admin-stats">
|
|
<StatusCard label="Total activities" value={total} icon="📋" />
|
|
<StatusCard label="On this page" value={activities.length} icon="📄" />
|
|
<StatusCard
|
|
label="Recent (≤1min)"
|
|
value={onlineCount}
|
|
state={onlineCount > 0 ? "ok" : "neutral"}
|
|
icon="🟢"
|
|
/>
|
|
</div>
|
|
|
|
<form
|
|
className="card"
|
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
|
|
>
|
|
<input
|
|
name="q"
|
|
defaultValue={q}
|
|
placeholder="Search action, description, IP..."
|
|
style={{ flex: 1, minWidth: 200 }}
|
|
/>
|
|
<input
|
|
name="staffId"
|
|
defaultValue={staffId?.toString()}
|
|
placeholder="Staff ID"
|
|
style={{ width: 120 }}
|
|
/>
|
|
<input
|
|
name="action"
|
|
defaultValue={action ?? ""}
|
|
placeholder="Action type (e.g., rank_change)"
|
|
style={{ width: 160 }}
|
|
/>
|
|
|
|
<button type="submit" className="btn btn-primary">
|
|
Filter
|
|
</button>
|
|
{(q || staffId || action) && (
|
|
<a href="/admin/logs" className="btn btn-outline">
|
|
Clear filters
|
|
</a>
|
|
)}
|
|
</form>
|
|
|
|
<section className="admin-section">
|
|
<h2>Activities ({total})</h2>
|
|
{activities.length === 0 ? (
|
|
<p className="muted">No staff activities match this search.</p>
|
|
) : (
|
|
<div style={{ overflowX: "auto" }}>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>When</th>
|
|
<th>Staff</th>
|
|
<th>Action</th>
|
|
<th>Description</th>
|
|
<th>Target</th>
|
|
<th>IP Address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activities.map((a) => (
|
|
<tr key={String(a.id)}>
|
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>{fromDate(a.createdAt)}</td>
|
|
<td className="muted">#{Number(a.userId)} ({nameById.get(Number(a.userId)) ?? "unknown"})</td>
|
|
<td>
|
|
<span className="admin-badge">{a.action}</span>
|
|
</td>
|
|
<td>{a.description}</td>
|
|
<td>
|
|
{a.targetType && a.targetId ? (
|
|
<span className="muted">{a.targetType === "user" ? "User " : ""}#{String(a.targetId)}</span>
|
|
) : (
|
|
<span className="muted">—</span>
|
|
)}
|
|
</td>
|
|
<td className="muted">{a.ipAddress || "—"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<p className="muted">
|
|
Page {page} / {pages} · {total} activities
|
|
{page < pages ? (
|
|
<>
|
|
{" · "}
|
|
<a href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&page=${page + 1}`}>Next →</a>
|
|
</>
|
|
) : null}
|
|
</p>
|
|
</main>
|
|
);
|
|
}
|
|
|