initial commit
This commit is contained in:
364
apps/frontend/src/management/pages/LogsPage.jsx
Normal file
364
apps/frontend/src/management/pages/LogsPage.jsx
Normal file
@@ -0,0 +1,364 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user