Files
bacchus/apps/frontend/src/management/pages/LogsPage.jsx
2025-08-28 14:24:06 +00:00

365 lines
13 KiB
JavaScript

import React, { useEffect, useMemo, useRef, useState } from "react";
import { getAuditLogs, getUsersLite } from "../../api";
/* ----------------------------- kleine Utils ----------------------------- */
function euroDelta(newC, oldC) {
if (newC == null || oldC == null) return null;
const d = (newC - oldC) / 100;
return (d >= 0 ? "+" : "") + d.toFixed(2) + " €";
}
function fmtDT(v) {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
/* -------------------------- NiceDropdown (inline) ------------------------ */
function NiceDropdown({
value, onChange, options, placeholder = "Auswählen…",
className = "", buttonClassName = "", menuClassName = "", minWidth = 160,
}) {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
const popRef = useRef(null);
const current = useMemo(
() => options?.find((o) => String(o.value) === String(value)) ?? null,
[options, value]
);
useEffect(() => {
function onDoc(e) {
if (!open) return;
if (popRef.current?.contains(e.target)) return;
if (btnRef.current?.contains(e.target)) return;
setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div className={classNames("relative", className)} style={{ minWidth }}>
<button
type="button"
ref={btnRef}
onClick={() => setOpen(o => !o)}
className={classNames(
"w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2",
"text-left flex items-center justify-between hover:bg-gray-700",
buttonClassName
)}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className={current ? "" : "text-white/60"}>
{current?.label ?? placeholder}
</span>
<span className="text-white/60">▾</span>
</button>
{open && (
<div
ref={popRef}
className={classNames(
"absolute z-20 mt-2 w-full rounded-xl border p-1 shadow-2xl",
"bg-gray-900 border-gray-700",
menuClassName
)}
role="listbox"
>
{options.map(opt => {
const active = String(opt.value) === String(value);
return (
<button
key={String(opt.value)}
type="button"
onClick={() => { onChange(opt.value); setOpen(false); }}
className={classNames(
"w-full text-left px-3 py-2 rounded-lg transition",
active ? "bg-gray-700 text-white" : "text-gray-200 hover:bg-gray-800"
)}
role="option"
aria-selected={active}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
/* ------------------------ UserPicker (Such-Combobox) --------------------- */
function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const btnRef = useRef(null);
const popRef = useRef(null);
useEffect(() => {
let alive = true;
if (!open) return;
(async () => {
setLoading(true);
try {
const res = await getUsersLite({ limit: 20, offset: 0, q });
const arr = Array.isArray(res) ? res : (res.items || []);
if (alive) setItems(arr);
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, [open, q]);
useEffect(() => {
function onDoc(e) {
if (!open) return;
if (popRef.current?.contains(e.target)) return;
if (btnRef.current?.contains(e.target)) return;
setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const selected = items.find(u => String(u.id) === String(value));
return (
<div className="relative" style={{ minWidth: 220 }}>
<button
type="button"
ref={btnRef}
onClick={() => setOpen(o => !o)}
className="w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700"
>
<span className={value ? "" : "text-white/60"}>
{selected ? (selected.name || selected.alias || selected.email) : placeholder}
</span>
<span className="text-white/60">▾</span>
</button>
{open && (
<div
ref={popRef}
className="absolute z-20 mt-2 w-[280px] rounded-xl border border-gray-700 bg-gray-900 p-2 shadow-2xl"
>
<input
autoFocus
className="w-full px-3 py-2 mb-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
placeholder="Suchen…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="max-h-64 overflow-auto">
{loading ? (
<div className="px-3 py-2 text-white/60 text-sm">Laden…</div>
) : (
<>
<button
className="w-full text-left px-3 py-2 rounded-lg text-gray-200 hover:bg-gray-800"
onClick={() => { onChange(""); setOpen(false); }}
>
Alle Nutzer
</button>
{items.map(u => (
<button
key={u.id}
className="w-full text-left px-3 py-2 rounded-lg text-gray-200 hover:bg-gray-800"
onClick={() => { onChange(u.id); setOpen(false); }}
>
{(u.name || u.alias || u.email) + (u.alias ? ` · ${u.alias}` : "")}
</button>
))}
</>
)}
</div>
</div>
)}
</div>
);
}
/* --------------------------------- Page ---------------------------------- */
export default function LogsPage() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [hasMore, setHasMore] = useState(true);
// Filter-Params
const [userId, setUserId] = useState("");
const [action, setAction] = useState("");
const [range, setRange] = useState("7d"); // 24h, 7d, 30d, all
const [query, setQuery] = useState("");
const [offset, setOffset] = useState(0);
const LIMIT = 100;
function computeRange(r) {
if (r === "all") return {};
const now = new Date();
let from = new Date(now);
if (r === "24h") from.setHours(now.getHours() - 24);
else if (r === "7d") from.setDate(now.getDate() - 7);
else if (r === "30d") from.setDate(now.getDate() - 30);
return { date_from: from.toISOString(), date_to: now.toISOString() };
}
async function load(reset = false) {
setLoading(true);
setErr(null);
try {
const pageOffset = reset ? 0 : offset;
const { date_from, date_to } = computeRange(range);
const data = await getAuditLogs({
limit: LIMIT,
offset: pageOffset,
user_id: userId || undefined,
action: action || undefined,
q: query || undefined,
date_from,
date_to,
});
const arr = Array.isArray(data) ? data : (data.items || []);
setRows((prev) => (reset ? arr : [...prev, ...arr]));
setHasMore(arr.length === LIMIT);
setOffset(pageOffset + arr.length);
} catch (e) {
setErr(e);
} finally {
setLoading(false);
}
}
// Initial + bei Filterwechsel neu laden
useEffect(() => {
load(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, action, range]);
// Clientseitige Fallback-Suche (falls Backend "q" ignoriert)
const visible = useMemo(() => {
if (!query) return rows;
const q = query.toLowerCase();
return rows.filter(r =>
(r.user?.name || "").toLowerCase().includes(q) ||
String(r.user_id ?? "").includes(q) ||
String(r.action ?? "").toLowerCase().includes(q) ||
String(r.info ?? "").toLowerCase().includes(q)
);
}, [rows, query]);
return (
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
<h2 className="text-white/90 font-semibold mb-4">Audit-Logs</h2>
{/* Toolbar */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<UserPicker value={userId} onChange={setUserId} />
<NiceDropdown
value={action}
onChange={setAction}
placeholder="Alle Aktionen"
options={[
{ value: "", label: "Alle Aktionen" },
{ value: "login", label: "Login" },
{ value: "logout", label: "Logout" },
{ value: "booking", label: "Buchung" },
{ value: "topup", label: "Aufladung" },
{ value: "adjust", label: "Kontokorrektur" },
{ value: "user_update", label: "User-Update" },
]}
/>
<NiceDropdown
value={range}
onChange={setRange}
placeholder="Zeitraum"
options={[
{ value: "24h", label: "Letzte 24h" },
{ value: "7d", label: "Letzte 7 Tage" },
{ value: "30d", label: "Letzte 30 Tage" },
{ value: "all", label: "Alles" },
]}
/>
<input
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-gray-800 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
placeholder="Suche (User, Aktion, Info)…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button
className="px-3 py-2 rounded-xl bg-gray-800 border border-gray-600 hover:bg-gray-700 text-gray-100"
onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }}
>
Zurücksetzen
</button>
</div>
{/* Tabelle */}
<div className="overflow-x-auto">
<table className="min-w-full text-sm text-white/80">
<thead className="text-white/60">
<tr>
<th className="text-left py-2 pr-4">Zeit</th>
<th className="text-left py-2 pr-4">User</th>
<th className="text-left py-2 pr-4">Aktion</th>
<th className="text-left py-2 pr-4">Info</th>
<th className="text-left py-2 pr-4">Δ&nbsp;Balance</th>
</tr>
</thead>
<tbody>
{visible.map((r) => (
<tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10">
<td className="py-2 pr-4 font-mono">{fmtDT(r.timestamp)}</td>
<td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td>
<td className="py-2 pr-4 font-mono">{String(r.action ?? "—")}</td>
<td className="py-2 pr-4">{r.info ?? "—"}</td>
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "—"}</td>
</tr>
))}
{loading && (
<tr className="border-t border-white/10">
<td className="py-3 pr-4 text-white/60" colSpan={5}>Laden…</td>
</tr>
)}
{!loading && visible.length === 0 && (
<tr className="border-t border-white/10">
<td className="py-3 pr-4 text-white/60" colSpan={5}>Keine Einträge.</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="mt-4 flex items-center gap-3">
<button
className="px-4 py-2 rounded-xl bg-white/10 border border-white/10 hover:bg-white/15 text-white/80 disabled:opacity-50"
onClick={() => load(false)}
disabled={loading || !hasMore}
>
Mehr laden
</button>
<div className="text-white/50 text-xs">
{rows.length} geladen{hasMore ? " (Mehr verfügbar)" : ""}
</div>
</div>
</div>
);
}