365 lines
13 KiB
JavaScript
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">Δ 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>
|
|
);
|
|
}
|