initial commit
This commit is contained in:
275
apps/frontend/src/management/ManagementLayout.jsx
Normal file
275
apps/frontend/src/management/ManagementLayout.jsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React, { useEffect, useState, Suspense, useMemo } from "react";;
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
FiCpu,
|
||||
FiHome,
|
||||
FiUsers,
|
||||
FiPackage,
|
||||
FiTruck,
|
||||
FiBarChart2,
|
||||
FiSettings,
|
||||
FiLogOut,
|
||||
FiActivity,
|
||||
FiCalendar,
|
||||
FiCreditCard,
|
||||
FiList,
|
||||
} from "react-icons/fi";
|
||||
import { logout, getCapabilities, API_BASE } from "../api";
|
||||
|
||||
/* ---------- Hintergrund ---------- */
|
||||
function BackgroundFX({ children }) {
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<div
|
||||
className="absolute inset-0 -z-20"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(1200px 600px at 70% 0%, rgba(0,120,255,0.08), transparent 60%)," +
|
||||
"radial-gradient(1000px 600px at 30% 100%, rgba(0,255,170,0.06), transparent 60%)," +
|
||||
"linear-gradient(180deg, #0a0a0a 0%, #0b1020 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(transparent 24px, rgba(255,255,255,0.05) 25px), linear-gradient(90deg, transparent 24px, rgba(255,255,255,0.05) 25px)",
|
||||
backgroundSize: "25px 25px",
|
||||
maskImage:
|
||||
"radial-gradient(70% 60% at 50% 50%, black 60%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Navigation ---------- */
|
||||
const NAV_ALL = [
|
||||
{ cap: "dashboard", to: "/management", label: "Dashboard", icon: FiHome, end: true },
|
||||
{ cap: "my-bookings", to: "/management/bookings", label: "Buchungen", icon: FiCalendar },
|
||||
{ cap: "my-transactions", to: "/management/transaction", label: "Transaktion", icon: FiCreditCard },
|
||||
{ cap: "system-config", to: "/management/settings", label: "Einstellungen", icon: FiSettings },
|
||||
{ cap: "users", to: "/management/users", label: "Nutzer", icon: FiUsers },
|
||||
{ cap: "products", to: "/management/products", label: "Produkte", icon: FiPackage },
|
||||
{ cap: "deliveries", to: "/management/deliveries", label: "Lieferungen", icon: FiTruck },
|
||||
{ cap: "stats-advanced", to: "/management/stats", label: "Statistiken", icon: FiBarChart2 },
|
||||
{ cap: "transactions", to: "/management/transactions", label: "Transaktionen", icon: FiList },
|
||||
{ cap: "audit-logs", to: "/management/logs", label: "Logs", icon: FiActivity },
|
||||
];
|
||||
|
||||
// Route-Chunks für Prefetch
|
||||
const prefetchMap = {
|
||||
"/management": () => import("./pages/DashboardPage"),
|
||||
"/management/bookings": () => import("./pages/BookingsPage"),
|
||||
"/management/transaction": () => import("./pages/TransactionPage"),
|
||||
"/management/transactions":() => import("./pages/AdminTransactionsPage"),
|
||||
"/management/users": () => import("./pages/UsersPage"),
|
||||
"/management/products": () => import("./pages/ProductsPage"),
|
||||
"/management/deliveries": () => import("./pages/DeliveriesPage"),
|
||||
"/management/stats": () => import("./pages/StatsPage"),
|
||||
"/management/logs": () => import("./pages/LogsPage"),
|
||||
"/management/settings": () => import("./pages/SettingsPage"),
|
||||
};
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
function Sidebar({ capabilities, collapsed, onToggle }) {
|
||||
const navItems = NAV_ALL.filter((item) => capabilities.includes(item.cap));
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
"h-screen sticky top-0 bg-white/5 backdrop-blur-md border-r border-white/10 px-3 py-4 transition-all duration-200",
|
||||
collapsed ? "w-[72px]" : "w-[260px]",
|
||||
].join(" ")}
|
||||
aria-label="Seitenleiste"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={[
|
||||
"p-2 rounded-xl border transition",
|
||||
"border-cyan-400/30 bg-cyan-500/10 hover:bg-cyan-500/20",
|
||||
"focus:outline-none focus:ring-2 focus:ring-cyan-400/40",
|
||||
].join(" ")}
|
||||
title={collapsed ? "Sidebar ausklappen" : "Sidebar einklappen"}
|
||||
aria-label={collapsed ? "Sidebar ausklappen" : "Sidebar einklappen"}
|
||||
>
|
||||
<FiCpu className="text-cyan-300" />
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="leading-tight select-none">
|
||||
<div className="text-white font-semibold">Bacchus</div>
|
||||
<div className="text-white/50 text-xs font-mono">management</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onMouseEnter={() => prefetchMap[to]?.()}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 px-3 py-2 rounded-xl transition group",
|
||||
"hover:bg-white/10",
|
||||
isActive
|
||||
? "bg-cyan-500/15 text-cyan-200 border border-cyan-400/30"
|
||||
: "text-white/70 border border-transparent",
|
||||
collapsed ? "justify-center" : "",
|
||||
].join(" ")
|
||||
}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="shrink-0" />
|
||||
{!collapsed && <span className="text-sm">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* kleiner Status unten */}
|
||||
<div className={["mt-8", collapsed ? "px-0" : "px-2"].join(" ")}>
|
||||
<div
|
||||
className={[
|
||||
"rounded-2xl bg-white/5 backdrop-blur-md",
|
||||
"border border-cyan-400/20",
|
||||
"shadow-[0_0_0_1px_rgba(34,211,238,0.05)]",
|
||||
"p-3",
|
||||
].join(" ")}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="text-xs text-white/60 mb-1 font-mono">
|
||||
…Connecting to Bacchus DB…
|
||||
</div>
|
||||
)}
|
||||
<div className="h-1.5 w-full bg-white/10 rounded overflow-hidden">
|
||||
<div className="h-full w-1/3 bg-cyan-400/50 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Topbar (mit Avatar aus Backend) ---------- */
|
||||
const DEFAULT_AVATAR = "/avatar-default.png";
|
||||
|
||||
function Topbar({ user, onLogout }) {
|
||||
const avatarSrc = useMemo(() => {
|
||||
const raw = (user?.avatar_url || "").trim();
|
||||
if (!raw) return DEFAULT_AVATAR; // kein Avatar gesetzt
|
||||
if (/^(https?:|data:|blob:|\/\/)/i.test(raw)) return raw; // absolute/data/blob
|
||||
// relative Backend-URL präfixen
|
||||
return `${API_BASE}${raw.startsWith("/") ? raw : `/${raw}`}`;
|
||||
}, [user?.avatar_url, API_BASE]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-16 flex items-center justify-between px-4
|
||||
border-b border-white/10 bg-white/5 backdrop-blur-md"
|
||||
>
|
||||
<div className="text-white/60 text-sm select-none"></div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="flex items-center gap-3 px-3 py-1.5 rounded-xl bg-white/5 border border-white/10"
|
||||
title="current user"
|
||||
>
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={user?.name || user?.alias || user?.email || "Avatar"}
|
||||
onError={(e) => { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-cyan-400/30"
|
||||
/>
|
||||
<div className="leading-tight">
|
||||
<div className="text-white text-sm font-semibold">
|
||||
{user?.name ?? "Admin"}
|
||||
</div>
|
||||
<div className="text-white/50 text-xs font-mono">
|
||||
{(user?.role ?? "manager").toString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-xl
|
||||
bg-red-500/15 text-red-200 border border-red-400/30
|
||||
hover:bg-red-500/25 transition"
|
||||
title="Logout"
|
||||
>
|
||||
<FiLogOut />
|
||||
<span className="text-sm">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Layout-Hülle ---------- */
|
||||
export default function ManagementLayout({ currentUser }) {
|
||||
const navigate = useNavigate();
|
||||
const [capabilities, setCapabilities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getCapabilities();
|
||||
setCapabilities(Array.isArray(data.capabilities) ? data.capabilities : []);
|
||||
} catch {
|
||||
setCapabilities(["dashboard", "profile", "my-bookings"]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
navigate("/management-login", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-white">Lade Managementbereich …</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BackgroundFX>
|
||||
<div className="flex">
|
||||
<Sidebar
|
||||
capabilities={capabilities}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed((v) => !v)}
|
||||
/>
|
||||
<main className="flex-1 min-h-screen">
|
||||
<Topbar user={currentUser} onLogout={handleLogout} />
|
||||
<section
|
||||
className="p-6 transition-all"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "800px" }}
|
||||
>
|
||||
<Suspense fallback={<div className="text-white/80 p-6">Lade…</div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</BackgroundFX>
|
||||
);
|
||||
}
|
143
apps/frontend/src/management/ManagementRoutes.jsx
Normal file
143
apps/frontend/src/management/ManagementRoutes.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { lazy } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import ManagementLayout from "./ManagementLayout";
|
||||
|
||||
// Route-Level Code-Splitting
|
||||
const DashboardPage = lazy(() => import("./pages/DashboardPage"));
|
||||
const BookingsPage = lazy(() => import("./pages/BookingsPage"));
|
||||
const TransactionPage = lazy(() => import("./pages/TransactionPage"));
|
||||
const AdminTransactionsPage = lazy(() => import("./pages/AdminTransactionsPage"));
|
||||
const UsersPage = lazy(() => import("./pages/UsersPage"));
|
||||
const ProductsPage = lazy(() => import("./pages/ProductsPage"));
|
||||
const DeliveriesPage = lazy(() => import("./pages/DeliveriesPage"));
|
||||
const StatsPage = lazy(() => import("./pages/StatsPage"));
|
||||
const LogsPage = lazy(() => import("./pages/LogsPage"));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
||||
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
|
||||
|
||||
export default function ManagementRoutes({ currentUser }) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<ManagementLayout currentUser={currentUser} />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="bookings" element={<BookingsPage />} />
|
||||
<Route path="transaction" element={<TransactionPage />} />
|
||||
<Route path="transactions" element={<AdminTransactionsPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
/*
|
||||
|
||||
Champ
|
||||
|
||||
|
||||
|
||||
|
||||
***+++++++++++++++++++++++++====================+*#%@@@@@@@@@@@@@@%*++======================================================================================+++***###%%%%###***++++===
|
||||
*++++++++++++++++++++++++++=====================++*%@@@@@@@@@@@@@@%*+======================================================================================++++***###%%%%###***++++===
|
||||
**++++++++++++++++++++++++=======================+*%@@@@@@@@@@@@@@@#++==============================----===================================================+++++***#######****+++++===
|
||||
**++++++++++++++++++++++++=======================+*%@@@@@@@@@@@@@@@#*+=================================---=================================================+++++*************+++++====
|
||||
**+++++++++++++++++++++++++++====================+*#@@@@@@@@@@@@@@@%*+=================================-===================================================+++++++++*******+++++++====
|
||||
+++++++++++++++++++++++++++++====================+*#%@@@@@@@@@@@@@@@#+===================----===----=====----=================================================+++++++++++++++++++=====
|
||||
+++++++++++++++++++++++++========================++*#%@@@@@@@@@@@@@@%*+================----===-----------------------------======================================+++++++++++++=+======
|
||||
+++++++++++++++++++++++++=========================+*#%%@@@@@@@@@@@@@%*+++============--------===--------------------------------------================================================
|
||||
++++++++++++++++++++++++++========================+*#%@@@@@@@@@@@@@@@#*##*##=========---------------------=-----------------------==--================================================
|
||||
++++++++++++++++++++++++++========================++*%@@@@@@@@@@@@@@@%#+**#+++======-----------------------------------------------===================================================
|
||||
++++++++++++++++++++++============================++*%@@@@@@@@@@@@@@@@+****##*+=====------------------------------------------------=*@%#=---=========================================
|
||||
+++++++++++++++++++++++++==========================+*##%%@@@@@@@@@@@@@*#*****%#*=====---------------------------------------------=+++*#*=------------================================
|
||||
+++++++++++++++++++++++++==========================++**#%%@@@@@@@@@@@@#%##****###+=-=-------------------------------------------=++*%##%#+=====-------================================
|
||||
+++++++++++++++++++++===============================++*#%@@@@@@@@@@@@##%###*++*%#%++=----------------------------------------==++*%#***###==-------===================================
|
||||
++++++++++++++++++==+===============================++*#%@@@@@@@@@@@@##%####*+++*%%%*+==-----------------------------------==++#%%######@%==-------===-===============================
|
||||
++++++++++++++++++++++===============================+*#%@@@@@@@@@@@@%*#***++**+*##%@**======---------------------------==+#*%@%%%##**#%@#==-----------======--=======================
|
||||
+++++++++++++++++==+++===============================+*#%%@@@@@@@@@@@@*#****+++++*###@%#@##*+==--------=-----------=====#%*%%%%#+*+*#%%%@*---=====-------=============================
|
||||
+++++++++++++++++====================================++*#%%%%@@@@@@@@@#***++=====+*##%@##@@@@##**++====+===-=====++%%%%%+@@%%%#*##**%%@@%==--------------=============================
|
||||
++++++++++++++========================================+*##%%%@@@@@@@@@@***++=====++*%@@@%#@@@###@%*%%%%%@##%**%*#@%@@@*%@@@#***+***#*%%@*------------------===========================
|
||||
+++++==+++++++========================================++*#%%@@@@@@@@@@@***#*++++++*###@@@*@@@@#%@%+#%%@@@***%@##@@@@@+@@@%#**++*++*##%%#=--------------------=========================
|
||||
++++===================================================+*#%%%%@@@@@@@@@@%****+++**##%%@@@%@@%%#+%%@@@@@@@@@+%%%@@@@@*%@@@@%*+++++++#%%%+-----------------------=======================
|
||||
+++++++++==============================================+**#%%%%@@@@@@@@@#+*##***#*****#%#%##*@%@@#%@@%@%@@%@@@*%%@@%%@@@@@%#*******###+----------------==------=======================
|
||||
+++++++++==============================================++*#%%%%@@@@@@@@%+*#%%@%*++++++#%%%%@@@@@%@###@@@#@#@@@@@%#%%#%%%%@@%@%#%@@%#@@#----------------------=========================
|
||||
++++++++================================================+*#%%%@@@@@@@@@#+#%%%##+=++*###%@%%%@@@@@*#@@@@@@@#%@@@@@@@#@###**#%%%@@@@@@#@*----------------------=========================
|
||||
+++++++++===============================================++*#%@@@@@@@@@@%+#@@#+==+**#%%##%%%%#%@@@*#@@@@@@%**@@@%%#@@@%%##****#@@@@@@##=------------------------=======================
|
||||
+++++++++=======================================------===+*#%@@@@@@@@@@@%%@%**+**#%#%##%##%#%@@@@#+#%@@@@*#@@@%@#%#%%%%@@%##***%@@@@#+=------------------------=======================
|
||||
+++++==============================================--====+*#%@@@@@@@%%##%%*#*#%#######%%@##%%%#@@%*%@@@@@+*@@%%@*#%%*#%%%@@%#*##@@@@#==------------------------=======================
|
||||
+++=++==============================---==-------=========+*#%@@@@@@%%#*#%#*******#%%%@%@%%+%@#*@@@##@@@@@*@@#+%@##%%@%%*%@@@%%%%%@@@%+=------------------------=======================
|
||||
++++============================--------------------=====+*#@@@@@%###****#+=+*###%%%#%@#++*%@%**@@#@@@@@#@@@**@@%+*##@@@%*#@%@%%%%%@@+---------------------------=====================
|
||||
+++=============================----------------------===+*%@@@@%####++++==+*##**+++*+*#*=+@@#++@@@@@@@@%@@#+#@@#+*%%@###***%##*%@@%%#=--------------------------=====================
|
||||
++===========================-------------------------==+*#@@@%%%##%*++*++=+#*##*++*+***+++@@#++%%%@@@@%%%%*+%@@+++*#**+**##%#**#%%#%#+=----------------------------==================
|
||||
++========================----------------------------=+*#@@@%%%###%+*+++*%@@@@%%@@@@@@@@@**%%#####%%@%#**##%@@%*+**#@%#***#@%%#*%%%#*=-----------------------------==================
|
||||
++========================---------------------------==+#%@@@%%###****++#%@%#**++*@%***@@@@@#+*##*##%%%#%%@@@@%%@@@@@@@@@@@%@%@%%#@%##+-------------------------------================
|
||||
++=====================-----------------------------==+#%@@@@%%%%###*++#@@#+++=+==@@***%@%%#@#==**###%%%%@%##@@@@@@@@@@@@#*%@@@@@@%%%#*=----------------------------------============
|
||||
======================-----------------------------==+#%@@@@%%#%%%###+%@@+=+++**+==@@#**#**@@@=-++***###%%++@@@@@@@@%%%@+=+=++*%@@@%%#*=----------------------------------============
|
||||
+====================-----------------------------==+#%@@@@@%########%%*++++*###*++==*@@@@@@@@--==****#*++=+@@@@@%%%%@%==++**+#@@@@@%%*=------------------------------------==========
|
||||
=====================-----------------------------=+*%@@@@%%########%#+===++++*#***+**+=---=*%+=++++*****+++@%****#+===+*####@#*%@@@@#==------------------------------================
|
||||
=====================----------------------------==*#@@@@%%########%*+=+====++++++*+=+***+=+@@#**+++++*###%@@*=--=++#+*#%%#%###%%%@@@*=------------------------------=================
|
||||
====================-----------------------------=+#%@@@%%####*******++====++++**++***++==*#%####*****##@@@%@@+=*#***+*##**%@%***##%*==------------------------------=================
|
||||
=====================-----------------------:---=+*#%@@%%######**##***++++%@@@%#****+=====+***#%####*###@@@%@#*+=+*##%%#*%@%%#*******+=----------------------------=---===============
|
||||
==================-----------------------------=+*#%@@@@%####***##****+*#%#***++++++---=+*++++#@%%%#*#%@@@%%%#*#*+=+*#**##%@%%%#*##**+==------------------------------================
|
||||
=====================--------------------------=+#%%@@@@%####********++++*#***##*+==-==**%+*==-+@@%%%%@@@#++###%%%%***#*###%%#%%%####+==------------------------------================
|
||||
=================-=---------------------------=+*#%@@@@%####***#######***+***+*#*++=-=*+++===-:-=#@@@@@+=-=++*+++++++#%@@@@@%%%@%###*++=---------------------------------=============
|
||||
================-----------------------------==+*%@@@@@%%#####*##%##******++**==**++-==++===----=+@@@@+=--=+*#+++++*++#%@@@%%@@%%%%%#*+=--------------------------------==============
|
||||
===============------------------------------=+*#%%@@@@#######*#%%#*****+==+++*#*#*+==-=+++++++++*#@@#+++**#####*##*##%@%*#@%%#%@@%#**+=-------------------------------===============
|
||||
==============------------------------------==+*#%%%%%%%#####**#%%#*++**#*++++++=+=======++*#%%%%@@@@%@@@@@@%%%##**=*#%@@@%%#*#@@@%#*++==-------------------------------==============
|
||||
===============----------------------------===+*#%%%%%%%#####**#*****#####**+++===++=++++++++++++++****#########*+#%%*#%#%#%@@@@%##**++==----------------------------------===========
|
||||
===============----------------------------==+**#%%@@@@%#####*+*#%#**#####*+++*+*+++++**++**+++++++*###*#######%##+%*%#+*#%@@@@@@@#*+++==------------------------------------=========
|
||||
================---------------------------==+*##%%@@@@%##**#####%#**####**+++*%%*#+**#*###%%%#%#*#####%%%%%%%%@%##+#+#%##@@@@@@@%##**+=------------------------------------==========
|
||||
=====================---------------------===+*#%%%%@@###########%#+**###***++*#%@*%@%%##%%@%@@@@@@@@@@@@@%@%%%%%#%#*@%@@@@@@@@@%###***=---------------------------------------=======
|
||||
==============================-----------====+*##%%%%@@@%##**#####*++**#*#+***+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@%@@@@@@@#@@#%####**==-------------------------------------========
|
||||
=====================================-=======+*##%%%%@@@%##****###*++*+##+***+**#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%#####**=--------------------------------------========
|
||||
============================================+**##%%@@@@@%##****###++++***#%%*****#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@%%%*%%####*+=-------------------------------------=======+
|
||||
+++++++++++++++++++=========================+*##%%%@@@@%%#######***++***###*###*##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%*%%####*===------------------=--------------========++
|
||||
+++++++++++++++++++++++++++================++*##%%%@@@%%%#####*****++*+**######*#####%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@%@@%%%*%%#*#*+===========================================++
|
||||
++++++++++++++++++++++++++++++++==========+++*##%%%%%%%%%###*******+++*****##%#*#####*****###@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%#%%*#%%**++==========================================++
|
||||
+++++++++++++++++++++++++++++++++++======+++**#%%%%%%%%##********#*++++*****###***#************###%%%@@@@@@@@%@@@@@@@%@%@@@%%%%%##%*###*++=======================================+++++
|
||||
+++++++++++++++++++++++++++++++++++++==+++++*##%%%%%%%##*++++++****++****+**********+++**********#***##%%%#%%%%%%%%@@#%%%%@@@%%%%%###@%#*+++=++++++++++++++++++=++++++++++++++++++++++
|
||||
+++++++++++++++++++++++++++++++++++++===+++**#%%%%%%%%#*+++++++*****+++***#***#******+****************#%%#*##%#%##%%@@%%@%%@@%%%%%%@@##**++=++++++++++++++++++++++++++++++++++++++++++
|
||||
+*******++++++++++++++++++++++++++++++++++**##%%%%%%%#*++==+++++********+***##*+***************#******#%%#####%###%@@@@@%%%%%%@@%%%%%####+++++++++++++++++++++++++++++++++++++++++++++
|
||||
**********++++++++++++++++++++++++++++++++**##%%%%%%##*++==+++++++***###*+++*##**++++*********#####***%@%####%%%%%%%@@@@%%@@@@@@@%%#%%%#*++++++++++++++++++++==+++++++++++++++++++++++
|
||||
************++++++++++++++++++++++++++++++**#%%%%%%%##*++++++++++++****#*+++++*********###########%###%%%%%%%%%%%%@@%%@@@@@@@%%%%%%%%#*+++++++++++++++++++++====++++++++++++++++++++++
|
||||
**************++++++++++++++++++++++++++++**#%%@@%%%##*+++++++++++***+++******++++*#####%%%%%%#%#%%%#%%%%%%%%%%%%%%%%%%%%%%%%#%%%%%##***+++++++++++++++++++++++=++++++++++++++++++++++
|
||||
**************++++++++++++++++++++*+++++++**#%%@@%%%%#**+++++*********++**###****+*+++**####%%%%%%%%%%%%@@@@@%@@@@%%%%%%%%%%%%%@%%###*******++++++++++++++++++++++++++++++++++++++++++
|
||||
######**********++++++++++++++++****+++++***##%@@@%%%##***+*************++++**#######***++**#%%%%%%@@%%@@@@@@@@@@@%%%%%%%@@@@@%%%%@%%%@@%##**+++++++++++++++++++++++++++++++++++++++**
|
||||
#########*****+++++++++++++++++***+*+++++***##%%%@@@%%##******######***##**+++++**#####****##%%%%%%@@@@@@@@@@@@@@@%%%@@@@@@@%%%@@@@@@@#**+*++++++++++++++++++++++++++++++++++*********
|
||||
#########*****++++++++++++++++++*******++***####%%%%%%%#####%%%%%%%##*+*#%%%*+++++++****#%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@###%@@@@%#*++++++++++++++++++++**********+*+****************
|
||||
########******++++++++++++++++++************#####%%%%###%%%%@@@@@@%%#*+++*%%##****++*****###%%%@@@@@@@@@@@@@@@@@@@@@@@@%#**#%@@%#****+++===++++***++++**********************#**#####**
|
||||
##########*****+++++++++++++++++************###%%%%%%#%%%%%@@@@@@%%#**++++++*#%@@%###%#####%@@@@@@@@@@@@@@@@@@@@@@@@@%##*#%@@%*+++******+++=++*************#######################%%**
|
||||
########********+++++++++++++++*************###%%%%%%%%%%%@@@@@@%##**+++++==+++*#%@@@%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@#*++++*******+++++++**********#####################%%%%%%**
|
||||
%######*************************************####%%%@@@@@@@@%%%%%#***+++***+====++*###%%%@@@@@@@@@@@@@@@@@@@%%#%%@@@@@@@@@#**+++++++*****+++++************##############%%%%%%%%%%%%%**
|
||||
%%%%####********#######*********************####%%%%@@@@%%%#####*****+*#%#*+===++******###%@@@@@@@@%%%%%%%##***##%%@@@@@##*+===+++******++*************#########%%%%%%%%%%%%%%%%%%%%**
|
||||
%%%%%%######################****************###%%%%%%%%%##********++++++*+*++++++++****####%%@@@@@@%%%%%%###*****###%%@%##*+++************************######%%%%%%%%%%%%%%%%%%%%%%%%#*
|
||||
%%%%%%%####################************#########%%%%%%%#*******+++++++++==++++**+++*#####%%%%@@@@@@@@@@@@%%####*****#####**+**+=+++*****+****+**###########%%%%%%%@@@%%%%%%%%%%%%%%%#*
|
||||
%%%%%%%########%%%%##%#####********####%%%%%#####%%%%%%##*##***********++=+++++++++******###%%@@@@@@@@@@@@%%%##*******##*****++++++++*#**+++*++*##########%%%%%@@@@@@@@@@@@@@@@%%@%%**
|
||||
%%%%%%%##%%####%%%########*****#######%%%%%%#####%%%%%%###%%%%##########**##%#*+++++++****##%@@@@%%%%%%%%%%%%##*******###**+++=+==++++*#*++++++#%%%%%%%%%%%%%%%%@@@@@@@@@@@@@@@%@@%%#*
|
||||
%%###%##%%%########****###**+*******#############%%%%%%%%%%@@@@@@@@@@%%%##%@@@@%****####%%%%@@@@@%%########%%%##****##%###*++=====+++++***+++**%%%@%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@#*
|
||||
#####%%%%######*****++*******************########%%%%%#%%%@@@@@@@@@@@@@%#*++*%@@@@@@@@@@@@@@@@@@@%########%%%%%%%%%%%%%%#+=======+++****+++**##%@@@@@@@%%%%@@@@@@@@@@@@@@@@@@@@@@@@@#*
|
||||
%###%%%%########%%%#**********************#######%%%%##%%%@@@@@@@@@@@@@@%*++++*#%@@@@@@@@@@@@@@@@%########%%@@@@@@@@@@@#========++++********##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
|
||||
%%%%@@@%%###%@@@@@@@@@%%%%#####****####################%%@@@@@@@@@@@@@@@%#*+++++++**##%%%%%%@@@@@@########%%@@@@@@@@@@#+======+++++*++****###%@@@@@%%%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@#*
|
||||
@@@@@@@%%###%%@@@@@@@@@@@@@@@@%%%%#%%%%%##%@@%%%%#####%%%@@@@@@@@@@@@@@@@@%##****++++++++++**#%@@%#*######%@@@@@@@@@@#+++=====+++++++++######@@@@@%%%%%%%%%%%%%%%%%%%%%@@@@@@@@@@@@@#*
|
||||
%@@@@%%##*+++++******##%%@@@@@@@@@@@@@@%##%@@@@@%%%%%%%%@@@@@@@@@@@@@@@@@@%###%%#*+++++++++++************#%@@@@@@@@@%+=+++=====++++***#@%##%@@@@@@%%%%%%@@%%%%%%%%%%%%%%%@@@@@@@@@@@#*
|
||||
%%%#**++======++++++++****#%@@@@@@@@@@@%%%@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#*+++++++++++++++++++++++******#%@@@@@@@@%+=++++++++=++*###%#####%@@@@@@@@@@@@@@@%%%%@@@@%%%%%@@@@@@@@@@@@#*
|
||||
#**++==========++++++*****+**%@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##***++==+++++++++++++++++++++*++++*#%@@@@@@@*++++++++++*+****###%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
|
||||
+++++===========++++**++*****#%%@@@%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%#******+==+*##*+++++++++**+++++***+++*#%@@@@@#+===+++++*+++*#%@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
|
||||
*+++++========++*++***+++******#####*****#%@@@@@@@@@@@@@@@@@@@@@@@%%#####%%#*+=++++==+++*###***++++**##*+++*#@@@%*+===+++********##%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
|
||||
+++++*++======++******++++*########*******#%%@@@@@@@@@@@@@@@@@@@@@%%%%@@@@@%#+++***++**###******+++*#%%#*++*%@@%+=====++*******#%%%%%%%@@@@@@@@@@@@@@@@@%%#####%%@@@@@@@@@@@@@@@@@@@#*
|
||||
#*++++**+===++++*####**+++*#%%%%%%%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#**#%@@%@@%%#*++++++++**##%#***#%%#+++=+++++*******##%%#%%%@@@@@@@@@%%%%####*******#%@@@@@@@@@%%%@@@@@@@#*
|
||||
@%##***++*++++*++*#*####**#%%@@%%%@@@@@@@@@%%%@%%@@@@@@@@@@@@@@@@@@@@@@@@@%%%#*+*#%@@@%*++++++++++#%%%##*#%%#**++=+++**#****#%%%%%%%%%@@@@@@@@@%%#####******###%@@@@@@@@@@%%%@@@@@@@#*
|
||||
%@@@@@@%#%#******###*#%%##%%%@@%@@@@@@@%@@@%#%@@@@@@@@@@@@%%%%@@@@%%%%%%%%%%%%#++++####*++****+*++*#%%%%##%%#+++==++*###%%#%%%@@@@@@#*#%@@@@@%%##%%@@@%%#######%%@@@@@@@@@%%%%@@@@@@#*
|
||||
@@@@@@@@@@@@@@@%@@@%#%%%@@@%%@@@@@@@@@@%@@@%%@@@@@@@%%@@@@@@@@@%%%%%%%%%%%@@@@%*+=+++++***###*++***##%%%%%%%%#++++*#%##%%%%%%###@@@%#*+*#%%##***#%@@@@@@@@@%%%%%%%#%%%@@@@%%%%%%@@@@#*
|
||||
@@@@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@%@%@@@@@@@@@@@%@@%%%%%%%%%#+++==++*###***+++##%%@%@%%@@@@@%%%%%%%@@@%#%%%%%@@%#*********++**%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@%*
|
||||
@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@%%%%#+==+++++*****++***%%@#@@@@@@@@@@@@@@@@@@%#%%@@@@@@%#***+******##@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@%#
|
||||
@@@@@%%@@@@@@@@%%@@@@@@@@@%%@@@@@@@@@@@@@%@@@@@@@@@@%@@@@@@%%@@@@@@@@@@@@@@@@%%#+==+++++++*******#%%%@@@@@@@@@@@@@@@@@@@%%%@@@@@@%#**#***####%%@@@@@@@@@@@@@@@@@@@@@@@@%%%@@@@@@@@@@%#
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
24
apps/frontend/src/management/components/NeonCard.jsx
Normal file
24
apps/frontend/src/management/components/NeonCard.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
|
||||
export default function NeonCard({
|
||||
className = "",
|
||||
as: Tag = "div",
|
||||
children,
|
||||
...rest
|
||||
}) {
|
||||
return (
|
||||
<Tag
|
||||
className={[
|
||||
"rounded-2xl bg-white/5 backdrop-blur-md",
|
||||
"border border-cyan-400/20 hover:border-cyan-400/40",
|
||||
"shadow-[0_0_0_1px_rgba(34,211,238,0.06)] hover:shadow-[0_0_22px_rgba(34,211,238,0.25)]",
|
||||
"transition-colors duration-200",
|
||||
className,
|
||||
].join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
178
apps/frontend/src/management/components/Table.jsx
Normal file
178
apps/frontend/src/management/components/Table.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
// src/management/components/Table.jsx
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Table (Dark + Nerdy)
|
||||
* - Dunkles Data-Table mit Hover-Highlight, ANSI-Style Statusfarben und optionaler Toolbar.
|
||||
* - Fokus auf Lesbarkeit im Dark Mode, Monospace-Option für numerische Spalten.
|
||||
*
|
||||
* Props:
|
||||
* - columns: Array<{
|
||||
* key: string; // Feldschlüssel in row
|
||||
* header: string; // Spaltenüberschrift
|
||||
* width?: string; // z.B. '120px' | '20%' (optional)
|
||||
* align?: 'left'|'center'|'right'; // Textausrichtung (default: left)
|
||||
* monospace?: boolean; // Monospace-Schrift für Werte
|
||||
* render?: (value, row) => ReactNode; // Custom-Renderer
|
||||
* }>
|
||||
* - data: Array<object> // Datensätze
|
||||
* - loading?: boolean
|
||||
* - error?: string
|
||||
* - emptyText?: string // Text bei leerer Tabelle
|
||||
* - onRowClick?: (row) => void
|
||||
* - toolbar?: React.ReactNode // Optionaler Toolbar-Content (rechts oben)
|
||||
* - dense?: boolean // kompaktere Zeilen
|
||||
*
|
||||
* Beispiele für Status-Farben (ANSI-Style):
|
||||
* <StatusBadge status="OK" />
|
||||
* <StatusBadge status="WARN" />
|
||||
* <StatusBadge status="FAIL" />
|
||||
*/
|
||||
|
||||
export function StatusBadge({ status }) {
|
||||
const s = String(status || "").toUpperCase();
|
||||
const cls =
|
||||
s === "OK"
|
||||
? "text-green-400"
|
||||
: s === "WARN"
|
||||
? "text-orange-400"
|
||||
: s === "FAIL"
|
||||
? "text-red-400"
|
||||
: "text-white/70";
|
||||
return <span className={`font-mono ${cls}`}>{s || "—"}</span>;
|
||||
}
|
||||
|
||||
export default function Table({
|
||||
columns = [],
|
||||
data = [],
|
||||
loading = false,
|
||||
error = "",
|
||||
emptyText = "Keine Daten",
|
||||
onRowClick,
|
||||
toolbar,
|
||||
dense = false,
|
||||
}) {
|
||||
const rowPad = dense ? "py-1.5" : "py-2.5";
|
||||
const cellPad = dense ? "py-1.5 pr-4" : "py-2.5 pr-4";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Header mit optionaler Toolbar */}
|
||||
<div className="flex items-center justify-end mb-2">{toolbar}</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-white/10 bg-white/5">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-white/60">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
"font-medium",
|
||||
cellPad,
|
||||
col.align === "right"
|
||||
? "text-right"
|
||||
: col.align === "center"
|
||||
? "text-center"
|
||||
: "text-left",
|
||||
].join(" ")}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-white/80">
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<tr>
|
||||
<td
|
||||
className={`${rowPad} pr-4 text-white/60`}
|
||||
colSpan={columns.length}
|
||||
>
|
||||
Lädt…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{!loading && error && (
|
||||
<tr>
|
||||
<td
|
||||
className={`${rowPad} pr-4 text-red-400`}
|
||||
colSpan={columns.length}
|
||||
>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!loading && !error && (!data || data.length === 0) && (
|
||||
<tr>
|
||||
<td
|
||||
className={`${rowPad} pr-4 text-white/50 italic`}
|
||||
colSpan={columns.length}
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Rows */}
|
||||
{!loading &&
|
||||
!error &&
|
||||
Array.isArray(data) &&
|
||||
data.map((row, idx) => {
|
||||
const clickable = typeof onRowClick === "function";
|
||||
return (
|
||||
<tr
|
||||
key={row.id ?? idx}
|
||||
className={[
|
||||
"border-t border-white/5",
|
||||
"hover:bg-white/5 transition",
|
||||
clickable ? "cursor-pointer" : "",
|
||||
].join(" ")}
|
||||
onClick={() => clickable && onRowClick(row)}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const value = row[col.key];
|
||||
const content = col.render
|
||||
? col.render(value, row)
|
||||
: value ?? "—";
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={[
|
||||
cellPad,
|
||||
col.align === "right"
|
||||
? "text-right"
|
||||
: col.align === "center"
|
||||
? "text-center"
|
||||
: "text-left",
|
||||
col.monospace ? "font-mono tabular-nums" : "",
|
||||
"align-middle",
|
||||
].join(" ")}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
title={
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
447
apps/frontend/src/management/pages/AdminTransactionsPage.jsx
Normal file
447
apps/frontend/src/management/pages/AdminTransactionsPage.jsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api";
|
||||
|
||||
const euro = (c) => (c ?? 0) / 100;
|
||||
const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" });
|
||||
const SHOW_CREATE = false;
|
||||
|
||||
const STATUS_LABELS_DE = {
|
||||
pending: "ausstehend",
|
||||
confirmed: "bestätigt",
|
||||
rejected: "abgelehnt",
|
||||
};
|
||||
const statusLabel = (s) => STATUS_LABELS_DE[s] ?? String(s ?? "");
|
||||
|
||||
function Pill({ status }) {
|
||||
const map = {
|
||||
pending: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
confirmed: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||
rejected: "bg-rose-500/20 text-rose-300 border-rose-500/30",
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs border ${map[status] || "bg-white/10 text-white/70 border-white/20"}`}>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Kleiner Avatar (Initialen) */
|
||||
function Initials({ label }) {
|
||||
const txt = String(label || "")
|
||||
.split(/\s+/).filter(Boolean).slice(0,2).map(s=>s[0]?.toUpperCase()).join("") || "•";
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 border border-white/15 flex items-center justify-center text-xs">
|
||||
{txt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Suchbare Combobox ohne externe Lib */
|
||||
function UserSelect({ users, value, onChange, placeholder = "— auswählen —" }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const btnRef = useRef(null);
|
||||
const popRef = useRef(null);
|
||||
const [hi, setHi] = useState(0);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const list = users || [];
|
||||
if (!needle) return list;
|
||||
return list.filter(u => {
|
||||
const s = `${u.alias || ""} ${u.name || ""} ${u.email || ""}`.toLowerCase();
|
||||
return s.includes(needle);
|
||||
});
|
||||
}, [users, q]);
|
||||
|
||||
const selected = useMemo(() => (users || []).find(u => u.id === value), [users, value]);
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e) {
|
||||
if (!open) return;
|
||||
if (
|
||||
popRef.current && popRef.current.contains(e.target)
|
||||
) return;
|
||||
if (
|
||||
btnRef.current && btnRef.current.contains(e.target)
|
||||
) return;
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
function onKey(e) {
|
||||
if (!open) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault(); setHi((i) => Math.min(i + 1, Math.max(0, options.length - 1)));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault(); setHi((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const pick = options[hi];
|
||||
if (pick) { onChange(pick.id); setOpen(false); }
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault(); setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" onKeyDown={onKey}>
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2 text-left flex items-center justify-between hover:bg-white/5"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{selected ? <Initials label={selected.alias || selected.name || selected.email} /> : <div className="w-6 h-6" />}
|
||||
<span className={selected ? "" : "text-white/50"}>
|
||||
{selected ? (selected.alias || selected.name || selected.email) : placeholder}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-white/50">▾</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={popRef}
|
||||
className="absolute z-20 mt-2 w-full rounded-xl border border-white/15 bg-black/80 backdrop-blur p-2 shadow-2xl"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setHi(0); }}
|
||||
placeholder="Suchen…"
|
||||
className="w-full mb-2 bg-black/40 text-white rounded-lg border border-white/20 px-3 py-2"
|
||||
/>
|
||||
<ul role="listbox" className="max-h-56 overflow-auto">
|
||||
{options.map((u, idx) => (
|
||||
<li
|
||||
key={u.id}
|
||||
role="option"
|
||||
aria-selected={u.id === value}
|
||||
onMouseEnter={() => setHi(idx)}
|
||||
onMouseDown={(e) => { e.preventDefault(); onChange(u.id); setOpen(false); }}
|
||||
className={`px-2 py-2 rounded-lg cursor-pointer flex items-center gap-2 ${
|
||||
idx === hi ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<Initials label={u.alias || u.name || u.email} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{u.alias || u.name || u.email}</div>
|
||||
<div className="text-xs text-white/50 truncate">{u.email || ""}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{!options.length && <li className="px-2 py-2 text-white/60">Keine Treffer</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function StatusSelect({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef(null);
|
||||
const popRef = useRef(null);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "", label: "alle" },
|
||||
{ value: "pending", label: "ausstehend" },
|
||||
{ value: "confirmed", label: "bestätigt" },
|
||||
{ value: "rejected", label: "abgelehnt" },
|
||||
];
|
||||
|
||||
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 current = OPTIONS.find(o => o.value === value) || OPTIONS[0];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="min-w-[160px] bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2 text-left flex items-center justify-between hover:bg-white/5"
|
||||
>
|
||||
<span>{current.label}</span>
|
||||
<span className="text-white/60">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popRef}
|
||||
className="absolute z-20 mt-2 w-full rounded-xl border border-white/15 bg-black/80 backdrop-blur p-1 shadow-2xl"
|
||||
>
|
||||
{OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg ${
|
||||
value === opt.value ? "bg-white/15 text-white" : "text-white/80 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function AdminTransactionsPage() {
|
||||
// Daten
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
// Nutzerliste (max 200)
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
// Filter
|
||||
const STATUS_OPTS = [
|
||||
{ value: "", label: "Alle" },
|
||||
{ value: "pending", label: "pending" },
|
||||
{ value: "confirmed", label: "confirmed" },
|
||||
{ value: "rejected", label: "rejected" },
|
||||
];
|
||||
|
||||
const [filterUserId, setFilterUserId] = useState(null); // Nutzer-Filter (Combobox)
|
||||
const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirmed", "rejected"
|
||||
|
||||
|
||||
|
||||
// Formular Neu
|
||||
const [form, setForm] = useState({ user_id: null, amount_eur: "", note: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const usersById = useMemo(() => {
|
||||
const m = new Map();
|
||||
users.forEach(u => m.set(u.id, u));
|
||||
return m;
|
||||
}, [users]);
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const [tx, us] = await Promise.all([
|
||||
listTopupsAdmin({
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
status_filter: statusFilter || undefined,
|
||||
user_id: filterUserId || undefined,
|
||||
}),
|
||||
getUsersLite({ limit: 200, offset: 0 }),
|
||||
]);
|
||||
|
||||
setRows(Array.isArray(tx) ? tx : (tx.items || []));
|
||||
setUsers(Array.isArray(us) ? us : (us.items || []));
|
||||
} catch (e) {
|
||||
setErr(e?.message || "Fehler beim Laden");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAll(); /* eslint-disable-next-line */ }, [statusFilter, filterUserId]);
|
||||
|
||||
async function onCreate(e) {
|
||||
e.preventDefault();
|
||||
const uid = Number(form.user_id);
|
||||
const eur = String(form.amount_eur ?? "").replace(",", ".");
|
||||
const cents = Math.round(Number(eur) * 100);
|
||||
if (!uid || !Number.isFinite(cents) || cents <= 0) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await createTopupAdmin({ user_id: uid, amount_cents: cents, note: form.note || "" });
|
||||
setRows(prev => [created, ...prev]);
|
||||
setForm({ user_id: null, amount_eur: "", note: "" });
|
||||
} catch (e) {
|
||||
alert(e?.message || "Top-up konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onApprove(id) {
|
||||
try {
|
||||
await patchTopupStatus(id, "confirmed");
|
||||
setRows(prev => prev.map(r => (r.id === id ? { ...r, status: "confirmed" } : r)));
|
||||
} catch (e) {
|
||||
alert(e?.message || "Bestätigen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
async function onReject(id) {
|
||||
try {
|
||||
await patchTopupStatus(id, "rejected");
|
||||
setRows(prev => prev.map(r => (r.id === id ? { ...r, status: "rejected" } : r)));
|
||||
} catch (e) {
|
||||
alert(e?.message || "Ablehnen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Formular Neu – ausgeblendet */}
|
||||
{SHOW_CREATE && (
|
||||
<form onSubmit={onCreate} className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<h2 className="text-white/90 font-semibold mb-4">Neue Aufladung / Admin-Transaktion</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<label className="block">
|
||||
<div className="text-xs text-white/60 mb-1">Nutzer</div>
|
||||
<UserSelect
|
||||
users={users}
|
||||
value={form.user_id}
|
||||
onChange={(id) => setForm(f => ({ ...f, user_id: id }))}
|
||||
placeholder="Nutzer wählen…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="text-xs text-white/60 mb-1">Betrag (€)</div>
|
||||
<input
|
||||
type="number" step="0.50" inputMode="decimal" placeholder=" 10,00"
|
||||
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
|
||||
value={form.amount_eur}
|
||||
onChange={(e) => setForm(f => ({ ...f, amount_eur: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:col-span-2">
|
||||
<div className="text-xs text-white/60 mb-1">Notiz</div>
|
||||
<input
|
||||
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
|
||||
placeholder="optional"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm(f => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="submit" disabled={submitting || !form.user_id}
|
||||
className="px-4 py-2 rounded-xl font-semibold bg-emerald-600/80 hover:bg-emerald-600 text-white shadow disabled:opacity-60"
|
||||
>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{/* Tabelle */}
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-white/90 font-semibold">Alle Transaktionen </h2>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Schöner Status-Filter */}
|
||||
<StatusSelect value={statusFilter} onChange={setStatusFilter} />
|
||||
|
||||
{/* Nutzer-Filter (schöne Combobox), rechts vom Status-Filter */}
|
||||
<div className="w-64">
|
||||
<UserSelect
|
||||
users={users}
|
||||
value={filterUserId}
|
||||
onChange={setFilterUserId}
|
||||
placeholder="Nutzer filtern…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadAll}
|
||||
className="text-sm px-3 py-2 rounded-xl border border-white/20 text-white/80 hover:bg-white/10"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="text-white/70">Lade…</div>
|
||||
) : err ? (
|
||||
<div className="text-rose-300 whitespace-pre-wrap">{String(err)}</div>
|
||||
) : (
|
||||
<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">ID</th>
|
||||
<th className="text-left py-2 pr-4">Zeit</th>
|
||||
<th className="text-left py-2 pr-4">Nutzer</th>
|
||||
<th className="text-left py-2 pr-4">Betrag (€)</th>
|
||||
<th className="text-left py-2 pr-4">Status</th>
|
||||
<th className="text-left py-2 pr-4">Notiz</th>
|
||||
<th className="text-left py-2 pr-4">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(r => {
|
||||
const u = usersById.get(r.user_id);
|
||||
const canAct = String(r.status) === "pending";
|
||||
const ts = r.created_at ? new Date(r.created_at) : null;
|
||||
return (
|
||||
<tr key={r.id} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4 font-mono">{r.id}</td>
|
||||
<td className="py-2 pr-4 font-mono">{ts ? fmtDT.format(ts) : "—"}</td>
|
||||
<td className="py-2 pr-4">{u?.name || u?.alias || u?.email || r.user_id}</td>
|
||||
<td className="py-2 pr-4 font-mono">{fmt.format(euro(r.amount_cents))}</td>
|
||||
<td className="py-2 pr-4"><Pill status={String(r.status)} /></td>
|
||||
<td className="py-2 pr-4">{r.note || "—"}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={!canAct}
|
||||
onClick={() => onApprove(r.id)}
|
||||
className="px-3 py-1 rounded-lg text-xs bg-emerald-600/80 hover:bg-emerald-600 text-white disabled:opacity-40"
|
||||
>
|
||||
Bestätigen
|
||||
</button>
|
||||
<button
|
||||
disabled={!canAct}
|
||||
onClick={() => onReject(r.id)}
|
||||
className="px-3 py-1 rounded-lg text-xs bg-rose-600/80 hover:bg-rose-600 text-white disabled:opacity-40"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!rows.length && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-white/60">Keine Einträge.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
117
apps/frontend/src/management/pages/BookingsPage.jsx
Normal file
117
apps/frontend/src/management/pages/BookingsPage.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getMyBookings, getProducts } from "../../api";
|
||||
|
||||
/** ---------- Zeit-Utils: Postgres-String -> RFC3339 ---------- */
|
||||
function normalizePgTimestamp(s) {
|
||||
if (!s || typeof s !== "string") return s;
|
||||
let t = s.replace(" ", "T");
|
||||
// Mikrosekunden (6 Stellen) -> Millisekunden (3 Stellen)
|
||||
t = t.replace(/(\.\d{3})\d+([Z+-])/, "$1$2");
|
||||
// Offset +HH / -HH -> +HH:00 / -HH:00
|
||||
t = t.replace(/([+-]\d{2})$/, "$1:00");
|
||||
return t;
|
||||
}
|
||||
function parseTs(s) {
|
||||
const iso = normalizePgTimestamp(s);
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
function formatTsBerlin(s) {
|
||||
const d = parseTs(s);
|
||||
if (!d) return "—";
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(d);
|
||||
}
|
||||
/** ------------------------------------------------------------- */
|
||||
|
||||
export default function BookingsPage() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [productMap, setProductMap] = useState(() => new Map()); // id -> name
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Buchungen + Produkte parallel laden (Products nur für Name-Lookup)
|
||||
const [bData, pData] = await Promise.all([
|
||||
getMyBookings(),
|
||||
getProducts().catch(() => []),
|
||||
]);
|
||||
if (!alive) return;
|
||||
|
||||
const items = Array.isArray(bData) ? bData : (bData.items || []);
|
||||
items.sort((a, b) => {
|
||||
const ta = parseTs(a.created_at ?? a.timestamp)?.getTime() ?? 0;
|
||||
const tb = parseTs(b.created_at ?? b.timestamp)?.getTime() ?? 0;
|
||||
return tb - ta;
|
||||
});
|
||||
setRows(items);
|
||||
|
||||
const map = new Map();
|
||||
if (Array.isArray(pData)) {
|
||||
for (const p of pData) {
|
||||
if (p && typeof p.id !== "undefined") map.set(p.id, p.name || String(p.id));
|
||||
}
|
||||
}
|
||||
setProductMap(map);
|
||||
} catch (e) {
|
||||
if (alive) setErr(e);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-white">Lade Buchungen…</div>;
|
||||
if (err) return <div className="text-white/70 text-sm">Keine Buchungen verfügbar (Endpoint fehlt oder keine Daten).</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<h2 className="text-white/90 font-semibold mb-3">Meine Buchungen</h2>
|
||||
<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">Produkt</th>
|
||||
<th className="text-left py-2 pr-4">Menge</th>
|
||||
<th className="text-left py-2 pr-4">Gesamt (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const name =
|
||||
r.product?.name ??
|
||||
r.product_name ??
|
||||
(typeof r.product_id !== "undefined"
|
||||
? productMap.get(r.product_id) ?? String(r.product_id)
|
||||
: "—");
|
||||
|
||||
return (
|
||||
<tr key={r.id ?? r.booking_id} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4 font-mono">
|
||||
{formatTsBerlin(r.created_at ?? r.timestamp)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{name}</td>
|
||||
<td className="py-2 pr-4 font-mono">
|
||||
{r.amount ?? r.quantity ?? 1}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono">
|
||||
{r.total_cents != null ? (r.total_cents / 100).toFixed(2) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
198
apps/frontend/src/management/pages/DashboardPage.jsx
Normal file
198
apps/frontend/src/management/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getCapabilities, getStatsSummary } from "../../api";
|
||||
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap } from "react-icons/fi";
|
||||
|
||||
function Panel({ title, icon: Icon, children, accent = "cyan" }) {
|
||||
const accentClasses =
|
||||
accent === "red"
|
||||
? "border-red-400/25 shadow-[0_0_0_1px_rgba(248,113,113,0.06)]"
|
||||
: accent === "amber"
|
||||
? "border-amber-400/25 shadow-[0_0_0_1px_rgba(251,191,36,0.06)]"
|
||||
: accent === "emerald"
|
||||
? "border-emerald-400/25 shadow-[0_0_0_1px_rgba(16,185,129,0.06)]"
|
||||
: "border-cyan-400/25 shadow-[0_0_0_1px_rgba(34,211,238,0.06)]";
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl bg-white/5 backdrop-blur-md border ${accentClasses} p-5`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className="text-white/80" />
|
||||
<h2 className="text-white/90 font-semibold">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [caps, setCaps] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [statsDenied, setStatsDenied] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const { capabilities } = await getCapabilities();
|
||||
if (!alive) return;
|
||||
const c = Array.isArray(capabilities) ? capabilities : [];
|
||||
setCaps(c);
|
||||
|
||||
if (c.includes("stats-advanced")) {
|
||||
try {
|
||||
const s = await getStatsSummary();
|
||||
if (!alive) return;
|
||||
setSummary(s || null);
|
||||
setStatsDenied(false);
|
||||
} catch (e) {
|
||||
// 403 oder andere Fehler -> degradiert anzeigen
|
||||
if (e?.response?.status === 403 || e?.status === 403) {
|
||||
setStatsDenied(true);
|
||||
setSummary(null);
|
||||
} else {
|
||||
// kein harter Crash – wir degradieren
|
||||
setSummary(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setErr(e);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- ab hier keine neuen Hooks mehr ---
|
||||
|
||||
// Ableitungen robust ohne useMemo/useCallback
|
||||
const balanceCents = summary?.my_balance_cents;
|
||||
const balanceText =
|
||||
typeof balanceCents === "number" ? (balanceCents / 100).toFixed(2) + " €" : "—";
|
||||
const balanceAccent =
|
||||
typeof balanceCents === "number"
|
||||
? balanceCents < 0
|
||||
? "red"
|
||||
: balanceCents < 500
|
||||
? "amber"
|
||||
: "emerald"
|
||||
: "cyan";
|
||||
|
||||
const stock =
|
||||
summary?.stock_overview && typeof summary.stock_overview === "object"
|
||||
? summary.stock_overview
|
||||
: null;
|
||||
|
||||
const sys = {
|
||||
users: summary?.num_users ?? "—",
|
||||
products: summary?.num_products ?? "—",
|
||||
bookings: summary?.num_bookings ?? "—",
|
||||
revenue:
|
||||
typeof summary?.total_revenue_cents === "number"
|
||||
? (summary.total_revenue_cents / 100).toFixed(2) + " €"
|
||||
: "—",
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-white">Lade Dashboard…</div>;
|
||||
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
|
||||
|
||||
return (
|
||||
<div className="grid xl:grid-cols-2 gap-6">
|
||||
{/* Kontostand */}
|
||||
<Panel title="Kontostand" icon={FiCreditCard} accent={balanceAccent}>
|
||||
<div className="text-4xl text-white font-mono tracking-tight">
|
||||
{balanceText}
|
||||
{balanceText !== "—" && (
|
||||
<span className="ml-2 text-white/40 text-lg align-middle"></span>
|
||||
)}
|
||||
</div>
|
||||
{balanceText === "—" && (
|
||||
<div className="text-white/60 text-sm mt-2">
|
||||
Kontostand verbinden
|
||||
<span className="hidden sm:inline"> </span>
|
||||
<span className="font-mono">/stats/summary</span>).
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Aufladen */}
|
||||
<Panel title="Aufladen" icon={FiZap}>
|
||||
<div className="text-white/70 text-sm mb-3">
|
||||
Guthaben erhöhen. Paypal link verbinden diesdas...
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/management/transaction"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl
|
||||
bg-emerald-500/20 text-emerald-200 border border-emerald-400/30
|
||||
hover:bg-emerald-500/30 transition"
|
||||
>
|
||||
<FiZap />
|
||||
<span>Jetzt aufladen</span>
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Getränkelage */}
|
||||
<Panel title="Getränkelage" icon={FiTrendingUp}>
|
||||
{stock ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{Object.entries(stock).map(([k, v]) => {
|
||||
const pct = Math.max(0, Math.min(1, Number(v) || 0));
|
||||
const barClass =
|
||||
pct < 0.25 ? "bg-red-400/60" : pct < 0.5 ? "bg-amber-400/60" : "bg-emerald-400/60";
|
||||
return (
|
||||
<div key={k} className="text-white/80 text-sm">
|
||||
<div className="mb-1 capitalize">{k}</div>
|
||||
<div className="h-2 w-full bg-white/10 rounded">
|
||||
<div
|
||||
className={`h-full ${barClass} rounded`}
|
||||
style={{ width: `${Math.round(pct * 100)}%` }}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(pct * 100)}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white/50 text-xs mt-1 font-mono">
|
||||
{Math.round(pct * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-sm">
|
||||
Gut gefüllt nächste Lieferungen am: 30.01.26
|
||||
<span className="hidden sm:inline"> </span>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Systemstatus */}
|
||||
<Panel title="Systemstatus" icon={FiDatabase}>
|
||||
<div className="text-white/80 text-sm space-y-1 font-mono leading-6">
|
||||
<div>Users: {sys.users}</div>
|
||||
<div>Products: {sys.products}</div>
|
||||
<div>Bookings: {sys.bookings}</div>
|
||||
<div>Revenue: {sys.revenue}</div>
|
||||
</div>
|
||||
|
||||
{!caps.includes("stats-advanced") && (
|
||||
<div className="text-white/60 text-xs mt-3">
|
||||
Hinweis: Erweiterte Statistiken sind nicht freigeschaltet (<code className="font-mono">stats-advanced</code>).
|
||||
</div>
|
||||
)}
|
||||
{caps.includes("stats-advanced") && statsDenied && (
|
||||
<div className="text-amber-200/90 text-xs mt-3">
|
||||
Zugriff verweigert (403). Prüfe Auth/Rolle/CSRF des Endpoints <code className="font-mono">/stats/summary</code>.
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
57
apps/frontend/src/management/pages/DeliveriesPage.jsx
Normal file
57
apps/frontend/src/management/pages/DeliveriesPage.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getDeliveries } from "../../api";
|
||||
|
||||
export default function DeliveriesPage() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getDeliveries();
|
||||
if (!alive) return;
|
||||
setRows(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
if (alive) setErr(e);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-white">Lade Lieferungen…</div>;
|
||||
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<h2 className="text-white/90 font-semibold mb-3">Lieferungen</h2>
|
||||
<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">ID</th>
|
||||
<th className="text-left py-2 pr-4">Produkt</th>
|
||||
<th className="text-left py-2 pr-4">Menge</th>
|
||||
<th className="text-left py-2 pr-4">Preis (€)</th>
|
||||
<th className="text-left py-2 pr-4">Notiz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((d) => (
|
||||
<tr key={d.id} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4 font-mono">{d.id}</td>
|
||||
<td className="py-2 pr-4">{d.product?.name ?? d.product_id}</td>
|
||||
<td className="py-2 pr-4 font-mono">{d.amount}</td>
|
||||
<td className="py-2 pr-4 font-mono">{((d.price_cents ?? 0)/100).toFixed(2)}</td>
|
||||
<td className="py-2 pr-4">{d.note ?? "–"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
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>
|
||||
);
|
||||
}
|
10
apps/frontend/src/management/pages/NotFoundPage.jsx
Normal file
10
apps/frontend/src/management/pages/NotFoundPage.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<h2 className="text-white/90 font-semibold mb-3">Nicht gefunden</h2>
|
||||
<p className="text-white/70 text-sm">Die angeforderte Seite existiert nicht.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
782
apps/frontend/src/management/pages/ProductsPage.jsx
Normal file
782
apps/frontend/src/management/pages/ProductsPage.jsx
Normal file
@@ -0,0 +1,782 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useDeferredValue, startTransition } from "react";
|
||||
import {
|
||||
getProducts,
|
||||
getCategories as apiGetCategories,
|
||||
renameCategory as apiRenameCategory,
|
||||
deleteCategory as apiDeleteCategory,
|
||||
updateProduct,
|
||||
} from "../../api";
|
||||
import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi";
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2);
|
||||
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const uniq = (arr) => Array.from(new Set(arr));
|
||||
const displayVal = (v) => (v === 0 ? 0 : v ?? "–"); // 0 nicht verschlucken
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Toolbar-States
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [category, setCategory] = useState("ALLE");
|
||||
|
||||
// Sortierung
|
||||
const [sortBy, setSortBy] = useState("name");
|
||||
const [sortDir, setSortDir] = useState("asc");
|
||||
|
||||
// Modals
|
||||
const [viewItem, setViewItem] = useState(null);
|
||||
const [editItem, setEditItem] = useState(null);
|
||||
const [catMgrOpen, setCatMgrOpen] = useState(false);
|
||||
const [reassignInfo, setReassignInfo] = useState(null); // {oldCat}
|
||||
|
||||
// Kategorien-State (Serverquelle + „ALLE“)
|
||||
const [categories, setCategories] = useState(["ALLE"]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshProductsAndCategories();
|
||||
}, []);
|
||||
|
||||
async function refreshProductsAndCategories() {
|
||||
try {
|
||||
const [data, cats] = await Promise.all([getProducts(), apiGetCategories()]);
|
||||
setRows(Array.isArray(data) ? data : []);
|
||||
setCategories(["ALLE", ...cats]);
|
||||
} catch (e) {
|
||||
setErr(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter + Suche
|
||||
const filtered = useMemo(() => {
|
||||
const q = deferredSearch.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
const catOk = category === "ALLE" || (r.category ?? "") === category;
|
||||
if (!catOk) return false;
|
||||
if (!q) return true;
|
||||
const hay = [r.name, r.category, r.supplier_number, String(r.id)]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [rows, category, deferredSearch]);
|
||||
|
||||
// Spalten
|
||||
const columns = useMemo(() => {
|
||||
const base = [
|
||||
{ id: "id", label: "ID", get: (p) => p.id, mono: true, type: "num" },
|
||||
{ id: "name", label: "Name", get: (p) => p.name ?? "" },
|
||||
{
|
||||
id: "price_cents",
|
||||
label: "Preis (€)",
|
||||
get: (p) => Number(p.price_cents ?? 0),
|
||||
render: (p) => euro(p.price_cents),
|
||||
mono: true,
|
||||
type: "num",
|
||||
},
|
||||
{ id: "category", label: "Kategorie", get: (p) => p.category ?? "" },
|
||||
{ id: "stock", label: "Bestand", get: (p) => (p.stock ?? p.stock === 0 ? p.stock : null), type: "num" },
|
||||
];
|
||||
const hasVolume = filtered.some((r) => r.volume_ml !== undefined && r.volume_ml !== null);
|
||||
if (hasVolume) {
|
||||
base.splice(4, 0, {
|
||||
id: "volume_ml",
|
||||
label: "Volumen (ml)",
|
||||
get: (p) => (p.volume_ml ?? p.volume_ml === 0 ? p.volume_ml : null),
|
||||
type: "num",
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}, [filtered]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const col = columns.find((c) => c.id === sortBy) ?? columns[0];
|
||||
const arr = filtered.slice();
|
||||
arr.sort((a, b) => {
|
||||
const av = col.get(a);
|
||||
const bv = col.get(b);
|
||||
let res;
|
||||
if (col.type === "num") {
|
||||
const ax = av == null ? -Infinity : Number(av);
|
||||
const bx = bv == null ? -Infinity : Number(bv);
|
||||
res = ax - bx;
|
||||
} else {
|
||||
res = cmp(String(av ?? ""), String(bv ?? ""));
|
||||
}
|
||||
return sortDir === "asc" ? res : -res;
|
||||
});
|
||||
return arr;
|
||||
}, [filtered, columns, sortBy, sortDir]);
|
||||
|
||||
const toggleSort = (id) => {
|
||||
if (id === sortBy) {
|
||||
startTransition(() => setSortDir((d) => (d === "asc" ? "desc" : "asc")));
|
||||
} else {
|
||||
startTransition(() => { setSortBy(id); setSortDir("asc"); });
|
||||
}
|
||||
};
|
||||
const sortMark = (id) => (id !== sortBy ? "∙" : sortDir === "asc" ? "▲" : "▼");
|
||||
|
||||
// Kategorie-Operationen
|
||||
const addCategory = (name) => {
|
||||
const n = (name || "").trim();
|
||||
if (!n || n === "ALLE") return;
|
||||
setCategories((prev) => (prev.includes(n) ? prev : [...prev, n].sort((a, b) => a.localeCompare(b, "de"))));
|
||||
};
|
||||
|
||||
async function onRenameCategory(oldName, newName) {
|
||||
const nn = (newName || "").trim();
|
||||
if (!nn || nn === "ALLE" || oldName === "ALLE") return;
|
||||
await apiRenameCategory(oldName, nn);
|
||||
await refreshProductsAndCategories();
|
||||
if (category === oldName) setCategory(nn);
|
||||
}
|
||||
|
||||
function requestDeleteCategory(cat) {
|
||||
if (cat === "ALLE") return;
|
||||
const affected = rows.filter((r) => r.category === cat).length;
|
||||
if (affected === 0) {
|
||||
setCategories((prev) => prev.filter((x) => x !== cat));
|
||||
if (category === cat) setCategory("ALLE");
|
||||
} else {
|
||||
setReassignInfo({ oldCat: cat });
|
||||
setCatMgrOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function performReassignAndDelete(oldCat, targetCat) {
|
||||
const tgt = (targetCat || "").trim();
|
||||
if (!tgt || tgt === "ALLE") return;
|
||||
await apiDeleteCategory(oldCat, tgt);
|
||||
await refreshProductsAndCategories();
|
||||
if (category === oldCat) setCategory(tgt);
|
||||
setReassignInfo(null);
|
||||
}
|
||||
|
||||
// Persistentes Speichern aus dem Edit-Dialog
|
||||
async function saveEditedProduct(updated) {
|
||||
const payload = {
|
||||
name: updated.name,
|
||||
category: updated.category,
|
||||
volume_ml: updated.volume_ml,
|
||||
price_cents: updated.price_cents,
|
||||
supplier_number: updated.supplier_number ?? null,
|
||||
stock: updated.stock,
|
||||
is_active: updated.is_active,
|
||||
};
|
||||
await updateProduct(updated.id, payload);
|
||||
await refreshProductsAndCategories();
|
||||
setEditItem(null);
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-white">Lade Produkte…</div>;
|
||||
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/5 border-2 border-cyan-400/20 p-5 text-white/80">
|
||||
<h2 className="text-white/90 font-semibold mb-4">Produkte</h2>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-4">
|
||||
{/* Kategorien */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCategory(c)}
|
||||
className={`px-3 py-1 rounded-full border transition ${
|
||||
category === c
|
||||
? "bg-cyan-600 border-cyan-400 text-white"
|
||||
: "bg-transparent border-cyan-400/30 text-white/80 hover:bg-cyan-600/30"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCatMgrOpen(true)}
|
||||
className="ml-1 inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 border border-white/20 hover:bg-white/15"
|
||||
>
|
||||
<FiEdit2 />
|
||||
<span>Kategorien</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suche */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Suchen (Name, Kategorie, ID, Lieferantennr.)"
|
||||
className="pl-9 pr-10 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/40 w-80 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="p-2 rounded-lg bg-white/10 hover:bg-white/15 border border-white/20"
|
||||
title="Suche löschen"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="overflow-x-auto" style={{ contentVisibility: "auto", containIntrinsicSize: "600px" }}>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-white/60">
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.id} className="text-left py-2 pr-4">
|
||||
<button
|
||||
onClick={() => toggleSort(c.id)}
|
||||
className="inline-flex items-center gap-2 hover:text-white focus:outline-none"
|
||||
aria-sort={c.id !== sortBy ? "none" : sortDir === "asc" ? "ascending" : "descending"}
|
||||
title={`Nach ${c.label} sortieren`}
|
||||
>
|
||||
<span>{c.label}</span>
|
||||
<span className="text-xs opacity-70">{sortMark(c.id)}</span>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-left py-2 pr-4">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((p) => (
|
||||
<tr key={p.id} className="border-t border-white/10">
|
||||
{columns.map((c) => (
|
||||
<td key={c.id} className={`py-2 pr-4 ${c.mono ? "font-mono" : ""}`}>
|
||||
{c.render ? c.render(p) : displayVal(c.get(p))}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setViewItem(p)}
|
||||
className="p-2 rounded bg-white/5 hover:bg-white/10 border border-white/10"
|
||||
title="Ansehen"
|
||||
aria-label="Ansehen"
|
||||
>
|
||||
<FiEye />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditItem(p)}
|
||||
className="p-2 rounded bg-white/5 hover:bg-white/10 border border-white/10"
|
||||
title="Bearbeiten"
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<FiEdit2 />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td className="py-4 text-white/60" colSpan={columns.length + 1}>
|
||||
Keine Produkte gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{viewItem && (
|
||||
<Modal onClose={() => setViewItem(null)} title="Produkt ansehen">
|
||||
<DetailView item={viewItem} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{editItem && (
|
||||
<Modal onClose={() => setEditItem(null)} title="Produkt bearbeiten">
|
||||
<EditForm
|
||||
item={editItem}
|
||||
categories={categories.filter((c) => c !== "ALLE")}
|
||||
onCancel={() => setEditItem(null)}
|
||||
onSave={saveEditedProduct}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{catMgrOpen && (
|
||||
<Modal onClose={() => setCatMgrOpen(false)} title="Kategorien verwalten">
|
||||
<CategoryManager
|
||||
categories={categories.filter((c) => c !== "ALLE")}
|
||||
onAdd={addCategory}
|
||||
onRename={onRenameCategory}
|
||||
onDelete={requestDeleteCategory}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{reassignInfo && (
|
||||
<Modal onClose={() => setReassignInfo(null)} title="Kategorie neu zuweisen">
|
||||
<ReassignCategoryForm
|
||||
oldCat={reassignInfo.oldCat}
|
||||
categories={categories.filter((c) => c !== "ALLE" && c !== reassignInfo.oldCat)}
|
||||
onCancel={() => setReassignInfo(null)}
|
||||
onConfirm={(targetCat) => performReassignAndDelete(reassignInfo.oldCat, targetCat)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Sub-Komponenten ---------- */
|
||||
|
||||
function Modal({ title, children, onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
|
||||
<h3 className="text-white/90 font-semibold">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded hover:bg-white/10 text-white/70"
|
||||
aria-label="Schließen"
|
||||
title="Schließen"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 text-white/80">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailView({ item }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InfoRow label="ID" value={<code>{item.id}</code>} />
|
||||
<InfoRow label="Name" value={item.name ?? "–"} />
|
||||
<InfoRow label="Preis" value={`${euro(item.price_cents)} €`} />
|
||||
<InfoRow label="Kategorie" value={item.category ?? "–"} />
|
||||
{"volume_ml" in item && <InfoRow label="Volumen (ml)" value={displayVal(item.volume_ml)} />}
|
||||
<InfoRow label="Bestand" value={displayVal(item.stock)} />
|
||||
<InfoRow label="Aktiv" value={item.is_active ? "ja" : "nein"} />
|
||||
<InfoRow label="Lieferantennr." value={item.supplier_number ?? "–"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/10 py-2">
|
||||
<div className="text-white/60">{label}</div>
|
||||
<div className="text-white">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== Edit-Form mit Animated Switch + Category Dropdown ===== */
|
||||
|
||||
function EditForm({ item, onSave, onCancel, categories }) {
|
||||
const [form, setForm] = useState({
|
||||
id: item.id,
|
||||
name: item.name ?? "",
|
||||
price_cents: Number(item.price_cents ?? 0),
|
||||
category: item.category ?? "",
|
||||
stock: item.stock ?? 0,
|
||||
volume_ml: item.volume_ml ?? "",
|
||||
is_active: item.is_active ?? true,
|
||||
supplier_number: item.supplier_number ?? "",
|
||||
});
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...item,
|
||||
...form,
|
||||
price_cents: Number(form.price_cents) || 0,
|
||||
stock: form.stock === "" ? null : Number(form.stock),
|
||||
volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Name">
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Kategorie">
|
||||
<CategorySelect
|
||||
value={form.category}
|
||||
onChange={(v) => set("category", v)}
|
||||
options={categories}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Field label="Preis (Cent)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.price_cents}
|
||||
onChange={(e) => set("price_cents", e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Volumen (ml)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.volume_ml}
|
||||
onChange={(e) => set("volume_ml", e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Bestand">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.stock}
|
||||
onChange={(e) => set("stock", e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Lieferantennr.">
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.supplier_number}
|
||||
onChange={(e) => set("supplier_number", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Aktiv">
|
||||
<Switch
|
||||
checked={!!form.is_active}
|
||||
onChange={(v) => set("is_active", v)}
|
||||
labelOn="aktiv"
|
||||
labelOff="inaktiv"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Preis (Anzeige)">
|
||||
<div className="px-3 py-2 rounded bg-white/5 border border-white/10 text-white/80">
|
||||
{euro(form.price_cents)} €
|
||||
</div>
|
||||
</Field>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded bg-white/10 border border-white/20 hover:bg-white/15"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded bg-cyan-600 hover:bg-cyan-700 text-white"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2"><FiCheck /> Speichern</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-white/60 mb-1">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== Schönes Kategorie-Dropdown mit Filter ===== */
|
||||
|
||||
function CategorySelect({ value, onChange, options, placeholder = "Kategorie wählen" }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
// Für stabile Anzeige ohne Duplikate/Nulls
|
||||
const cleanedOpts = useMemo(
|
||||
() => Array.from(new Set((options || []).filter(Boolean))),
|
||||
[options]
|
||||
);
|
||||
|
||||
// Klick außerhalb schließt Dropdown
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
<span className={`text-white/50 transition-transform ${open ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-xl bg-slate-900 border border-white/15 shadow-xl">
|
||||
<ul className="max-h-56 overflow-auto py-1" role="listbox">
|
||||
{cleanedOpts.length === 0 && (
|
||||
<li className="px-3 py-2 text-white/50">Keine Kategorien.</li>
|
||||
)}
|
||||
{cleanedOpts.map((opt) => (
|
||||
<li key={opt}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt);
|
||||
setOpen(false); // schließt nach Auswahl automatisch
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-white/10 ${
|
||||
value === opt ? "bg-cyan-600/30" : ""
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={value === opt}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ===== Animated Switch (ohne externe Lib) ===== */
|
||||
|
||||
function Switch({ checked, onChange, labelOn = "On", labelOff = "Off" }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-12 items-center rounded-full transition
|
||||
${checked ? "bg-cyan-600" : "bg-white/20"} focus:outline-none border border-white/20`}
|
||||
title={checked ? labelOn : labelOff}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition
|
||||
${checked ? "translate-x-6" : "translate-x-1"}`}
|
||||
/>
|
||||
<span className="sr-only">{checked ? labelOn : labelOff}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Kategorien-Manager ---- */
|
||||
|
||||
function CategoryManager({ categories, onAdd, onRename, onDelete }) {
|
||||
const [newName, setNewName] = useState("");
|
||||
const [renaming, setRenaming] = useState(null);
|
||||
const [renameVal, setRenameVal] = useState("");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
placeholder="Neue Kategorie"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const n = newName.trim();
|
||||
if (!n) return;
|
||||
onAdd(n);
|
||||
setNewName("");
|
||||
}}
|
||||
className="px-3 py-2 rounded bg-cyan-600 hover:bg-cyan-700 text-white inline-flex items-center gap-2"
|
||||
>
|
||||
<FiPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-white/10 border border-white/10 rounded-lg">
|
||||
{categories.length === 0 && (
|
||||
<li className="px-3 py-3 text-white/60">Keine eigenen Kategorien.</li>
|
||||
)}
|
||||
{categories.map((c) => (
|
||||
<li key={c} className="px-3 py-2 flex items-center justify-between">
|
||||
{renaming === c ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="flex-1 px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={renameVal}
|
||||
onChange={(e) => setRenameVal(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="px-3 py-2 rounded bg-cyan-600 hover:bg-cyan-700 text-white"
|
||||
onClick={() => {
|
||||
const val = renameVal.trim();
|
||||
if (val) onRename(c, val);
|
||||
setRenaming(null);
|
||||
setRenameVal("");
|
||||
}}
|
||||
>
|
||||
Umbenennen
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 rounded bg-white/10 border border-white/20"
|
||||
onClick={() => {
|
||||
setRenaming(null);
|
||||
setRenameVal("");
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-white">{c}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="p-2 rounded bg-white/5 hover:bg-white/10 border border-white/10"
|
||||
title="Kategorie umbenennen"
|
||||
onClick={() => {
|
||||
setRenaming(c);
|
||||
setRenameVal(c);
|
||||
}}
|
||||
>
|
||||
<FiEdit2 />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 rounded bg-white/5 hover:bg-white/10 border border-white/10 text-red-300"
|
||||
title="Kategorie löschen"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-white/50 text-sm">
|
||||
Hinweis: Das Löschen einer genutzten Kategorie erfordert die Neuzuordnung aller zugehörigen Produkte.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Reassign-Dialog ---- */
|
||||
|
||||
function ReassignCategoryForm({ oldCat, categories, onCancel, onConfirm }) {
|
||||
const [target, setTarget] = useState(categories[0] ?? "");
|
||||
const [custom, setCustom] = useState("");
|
||||
|
||||
const effectiveTarget = custom.trim() || target;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!effectiveTarget) return;
|
||||
onConfirm(effectiveTarget);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Die Kategorie <b>{oldCat}</b> wird aktuell verwendet. Bitte wähle eine{" "}
|
||||
<b>neue Kategorie</b> für alle betroffenen Produkte oder gib eine neue ein.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Zielkategorie auswählen">
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
{categories.length === 0 && <option value="">(keine vorhanden)</option>}
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="oder neue Zielkategorie anlegen">
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
placeholder="Neue Kategorie"
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded bg-white/10 border border-white/20 hover:bg-white/15"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded bg-cyan-600 hover:bg-cyan-700 text-white"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FiCheck /> Neuzuordnen & löschen
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
388
apps/frontend/src/management/pages/SettingsPage.jsx
Normal file
388
apps/frontend/src/management/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
getCurrentUser,
|
||||
updateOwnProfile,
|
||||
requestNewPin,
|
||||
uploadAvatar,
|
||||
API_BASE,
|
||||
} from "../../api";
|
||||
import {
|
||||
FiSave,
|
||||
FiKey,
|
||||
FiAtSign,
|
||||
FiTag,
|
||||
FiUpload,
|
||||
FiImage,
|
||||
FiAlertTriangle,
|
||||
FiDroplet,
|
||||
FiRefreshCw,
|
||||
} from "react-icons/fi";
|
||||
|
||||
/* ---------- Theme: Order-Background ---------- */
|
||||
const DEFAULT_BG1 = "#0a6bff"; // sanftes Blau
|
||||
const DEFAULT_BG2 = "#00e0aa"; // Türkisgrün
|
||||
|
||||
const toBool = (v) => v === true || v === 1 || v === "1" || v === "true";
|
||||
|
||||
|
||||
function readOrderColors() {
|
||||
const c1 = localStorage.getItem("order_bg1") || DEFAULT_BG1;
|
||||
const c2 = localStorage.getItem("order_bg2") || DEFAULT_BG2;
|
||||
return { c1, c2 };
|
||||
}
|
||||
function applyOrderColors(c1, c2) {
|
||||
document.documentElement.style.setProperty("--order-bg1", c1);
|
||||
document.documentElement.style.setProperty("--order-bg2", c2);
|
||||
window.dispatchEvent(new CustomEvent("order-bg-change", { detail: { bg1: c1, bg2: c2 } }));
|
||||
}
|
||||
|
||||
/* ---------- Komponente ---------- */
|
||||
export default function SettingsPage() {
|
||||
const [form, setForm] = useState({ alias: "", paypal: "", visible: true });
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPassword2, setNewPassword2] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
// Avatar
|
||||
const [avatarUrl, setAvatarUrl] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Theme-Farben
|
||||
const initial = readOrderColors();
|
||||
const [bg1, setBg1] = useState(initial.c1);
|
||||
const [bg2, setBg2] = useState(initial.c2);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const user = await getCurrentUser();
|
||||
setForm({
|
||||
alias: user.alias || "",
|
||||
paypal: user.paypal_email || "",
|
||||
visible: toBool(user.public_stats ?? user.visible_in_stats),
|
||||
});
|
||||
// Avatar-URL absolutieren, falls relativ
|
||||
const raw = user.avatar_url || null;
|
||||
setAvatarUrl(raw ? (raw.startsWith("http") ? raw : `${API_BASE}${raw}`) : null);
|
||||
|
||||
applyOrderColors(bg1, bg2);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
function setField(key, val) {
|
||||
setForm((f) => ({ ...f, [key]: val }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setLoading(true);
|
||||
setMsg(null);
|
||||
|
||||
// Passwortvalidierung
|
||||
if (newPassword || newPassword2 || currentPassword) {
|
||||
if (!currentPassword) return stop("Aktuelles Passwort ist erforderlich.");
|
||||
if (newPassword.length < 8) return stop("Neues Passwort zu kurz (min. 8 Zeichen).");
|
||||
if (newPassword !== newPassword2) return stop("Neue Passwörter stimmen nicht überein.");
|
||||
}
|
||||
|
||||
function stop(m) {
|
||||
setMsg(m);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const vis = toBool(form.visible);
|
||||
const patch = {
|
||||
alias: form.alias,
|
||||
paypal_email: form.paypal,
|
||||
public_stats: vis,
|
||||
visible_in_stats: vis,
|
||||
};
|
||||
|
||||
try {
|
||||
await updateOwnProfile(patch);
|
||||
const fresh = await getCurrentUser();
|
||||
setForm(f => ({ ...f, visible: toBool(fresh.public_stats ?? fresh.visible_in_stats) }));
|
||||
setMsg("Änderungen gespeichert.");
|
||||
|
||||
// Theme-Farben persistieren + anwenden
|
||||
localStorage.setItem("order_bg1", bg1);
|
||||
localStorage.setItem("order_bg2", bg2);
|
||||
applyOrderColors(bg1, bg2);
|
||||
|
||||
setMsg("Änderungen gespeichert.");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setNewPassword2("");
|
||||
} catch (e) {
|
||||
setMsg(e?.message || "Fehler beim Speichern.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewPin() {
|
||||
try {
|
||||
await requestNewPin();
|
||||
setMsg("Neuer PIN wurde angefordert.");
|
||||
} catch {
|
||||
setMsg("Fehler beim Anfordern des PIN.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Avatar ---
|
||||
function onFileChange(e) {
|
||||
const f = e.target.files?.[0];
|
||||
setFile(f || null);
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(f ? URL.createObjectURL(f) : null);
|
||||
}
|
||||
|
||||
async function onUploadAvatar() {
|
||||
if (!file) { setMsg("Bitte zuerst eine Bilddatei auswählen."); return; }
|
||||
setUploading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await uploadAvatar(file);
|
||||
if (res?.avatar_url) {
|
||||
const abs = res.avatar_url.startsWith("http") ? res.avatar_url : `${API_BASE}${res.avatar_url}`;
|
||||
// Cache-Bust
|
||||
setAvatarUrl(`${abs}?v=${Date.now()}`);
|
||||
setMsg("Profilbild aktualisiert.");
|
||||
} else {
|
||||
setMsg("Upload erfolgreich, aber keine Avatar-URL erhalten.");
|
||||
}
|
||||
setFile(null);
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(null);
|
||||
} catch (e) {
|
||||
setMsg("Fehler beim Hochladen des Profilbilds.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetColors() {
|
||||
setBg1(DEFAULT_BG1);
|
||||
setBg2(DEFAULT_BG2);
|
||||
applyOrderColors(DEFAULT_BG1, DEFAULT_BG2);
|
||||
}
|
||||
|
||||
const gradientStyle = {
|
||||
background: `linear-gradient(135deg, ${bg1} 0%, ${bg2} 100%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-4 md:p-6 space-y-6"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "900px" }}
|
||||
>
|
||||
<h1 className="text-white/90 text-xl font-semibold">Einstellungen</h1>
|
||||
|
||||
{/* Panels */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Profilbild */}
|
||||
<Panel title="Profilbild" icon={FiImage}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden bg-slate-800 border border-white/10 flex items-center justify-center">
|
||||
{preview ? (
|
||||
<img src={preview} alt="Preview" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||
) : avatarUrl ? (
|
||||
<img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span className="text-white/60 text-sm">Kein Bild</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*" // PNG/JPEG/GIF/WebP etc.
|
||||
onChange={onFileChange}
|
||||
className="block w-full text-sm text-white file:mr-4 file:py-2 file:px-3 file:rounded-lg file:border-0 file:bg-cyan-600 file:text-white hover:file:bg-cyan-700"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-white/50">Empfohlen: quadratisch, ≤ 5 MB.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
onClick={onUploadAvatar}
|
||||
disabled={uploading || !file}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:opacity-50 text-white rounded-lg border border-cyan-400/30"
|
||||
>
|
||||
<FiUpload /> {uploading ? "Lade hoch…" : "Hochladen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Theme für Order-Menü */}
|
||||
<Panel title="Order-Hintergrund" icon={FiDroplet}>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<label className="block">
|
||||
<div className="text-white/70 text-sm mb-1">Farbe 1</div>
|
||||
<input
|
||||
type="color"
|
||||
value={bg1}
|
||||
onChange={(e) => { setBg1(e.target.value); applyOrderColors(e.target.value, bg2); }}
|
||||
className="w-full h-10 rounded-lg bg-transparent cursor-pointer border border-white/10"
|
||||
title="Primäre Hintergrundfarbe"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-white/70 text-sm mb-1">Farbe 2</div>
|
||||
<input
|
||||
type="color"
|
||||
value={bg2}
|
||||
onChange={(e) => { setBg2(e.target.value); applyOrderColors(bg1, e.target.value); }}
|
||||
className="w-full h-10 rounded-lg bg-transparent cursor-pointer border border-white/10"
|
||||
title="Sekundäre Hintergrundfarbe"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Live-Preview */}
|
||||
<div
|
||||
className="mt-4 h-24 w-full rounded-xl border border-white/10 shadow-inner"
|
||||
style={gradientStyle}
|
||||
aria-label="Vorschau Order-Hintergrund"
|
||||
title={`${bg1} → ${bg2}`}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetColors}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white/10 border border-white/10 text-white/90 hover:bg-white/15"
|
||||
title="Standardfarben wiederherstellen"
|
||||
>
|
||||
<FiRefreshCw /> Zurücksetzen
|
||||
</button>
|
||||
<div className="text-white/50 text-xs self-center">
|
||||
Wird gespeichert und sofort angewandt.
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Profil-Angaben */}
|
||||
<Panel title="Profil" icon={FiTag}>
|
||||
<label className="block mb-3">
|
||||
<div className="text-white/70 text-sm mb-1">Alias</div>
|
||||
<input
|
||||
className={`w-full px-3 py-2 rounded-lg border focus:outline-none focus:ring-2 ${
|
||||
form.visible
|
||||
? "bg-white/10 border-white/20 text-white focus:ring-cyan-500"
|
||||
: "bg-white/5 border-white/10 text-white/40 cursor-not-allowed opacity-60"
|
||||
}`}
|
||||
value={form.alias}
|
||||
onChange={(e) => setField("alias", e.target.value)}
|
||||
disabled={!form.visible}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-white/80">In Statistiken sichtbar</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField("visible", !form.visible)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition ${
|
||||
form.visible ? "bg-cyan-500" : "bg-slate-600"
|
||||
}`}
|
||||
aria-pressed={form.visible}
|
||||
aria-label="Sichtbarkeit umschalten"
|
||||
title={form.visible ? "Sichtbar" : "Nicht sichtbar"}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${
|
||||
form.visible ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<div className="flex items-center gap-2 text-sm text-white/70 mb-1">
|
||||
<FiAtSign /> <span>PayPal-Mail</span>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
value={form.paypal}
|
||||
onChange={(e) => setField("paypal", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
{/* Sicherheit */}
|
||||
<Panel title="Sicherheit" icon={FiKey}>
|
||||
<div className="grid gap-3">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Aktuelles Passwort"
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Neues Passwort (min. 8 Zeichen)"
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Neues Passwort bestätigen"
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
value={newPassword2}
|
||||
onChange={(e) => setNewPassword2(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(newPassword || newPassword2 || currentPassword) && (
|
||||
<div className="mt-3 text-xs text-yellow-300 flex items-center gap-2">
|
||||
<FiAlertTriangle /> Zum Ändern muss das aktuelle Passwort korrekt eingegeben werden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleNewPin}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600/80 hover:bg-purple-600 text-white rounded-lg border border-purple-400/30"
|
||||
>
|
||||
<FiKey /> Neuen PIN anfordern
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:opacity-50 text-white font-semibold rounded-lg border border-cyan-400/30"
|
||||
>
|
||||
<FiSave /> {loading ? "Speichern…" : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <p className="mt-3 text-sm text-cyan-300">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Panel-Baustein ---------- */
|
||||
function Panel({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/5 backdrop-blur-md border border-cyan-400/25 shadow-[0_0_0_1px_rgba(34,211,238,0.06)] p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className="text-white/80" />
|
||||
<h2 className="text-white/90 font-semibold">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
457
apps/frontend/src/management/pages/StatsPage.jsx
Normal file
457
apps/frontend/src/management/pages/StatsPage.jsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FiBarChart2, FiTable, FiUsers } from "react-icons/fi";
|
||||
import {
|
||||
getStatsMeta,
|
||||
getTopDrinkers as apiTopDrinkers,
|
||||
getProductShare as apiProductShare,
|
||||
API_BASE,
|
||||
} from "../../api";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
PieChart, Pie, Cell, CartesianGrid, LabelList,
|
||||
} from "recharts";
|
||||
|
||||
/* ---------- Utils ---------- */
|
||||
const euro = (c) => (Number(c ?? 0) / 100).toFixed(2);
|
||||
|
||||
function useLocalState(key, initial) {
|
||||
const [v, setV] = useState(() => {
|
||||
try { const s = localStorage.getItem(key); return s ? JSON.parse(s) : initial; } catch { return initial; }
|
||||
});
|
||||
useEffect(() => { try { localStorage.setItem(key, JSON.stringify(v)); } catch {} }, [key, v]);
|
||||
return [v, setV];
|
||||
}
|
||||
|
||||
const absMedia = (u) => {
|
||||
if (!u) return "";
|
||||
// schon absolut?
|
||||
if (/^https?:\/\//i.test(u)) return u;
|
||||
// relativ -> an API_BASE anhängen
|
||||
return `${API_BASE}${u}`;
|
||||
};
|
||||
|
||||
const DEFAULT_AVATAR = "/avatar-default.png"; // liegt in /public
|
||||
|
||||
function resolveAvatar(raw) {
|
||||
const v = (raw ?? "").trim();
|
||||
if (!v) return DEFAULT_AVATAR; // nichts gesetzt
|
||||
if (/^(https?:|data:|blob:|\/\/)/i.test(v)) return v; // absolute/data/blob
|
||||
if (v === DEFAULT_AVATAR) return v; // Frontend-Asset nie präfixen
|
||||
return `${API_BASE}${v.startsWith("/") ? v : `/${v}`}`; // Backend-relativ → präfixen
|
||||
}
|
||||
|
||||
|
||||
const PALETTE = ["#60a5fa", "#a78bfa", "#34d399", "#f59e0b", "#f472b6", "#22d3ee", "#fb7185", "#84cc16", "#c084fc", "#facc15"];
|
||||
const djb2 = (str) => { let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i); return Math.abs(h); };
|
||||
const colorFor = (name) => PALETTE[djb2(String(name)) % PALETTE.length];
|
||||
|
||||
function aggregateTopN(items, n = 5) {
|
||||
const sorted = [...items].sort((a, b) => b.value - a.value);
|
||||
const top = sorted.slice(0, n);
|
||||
const rest = sorted.slice(n);
|
||||
const restSum = rest.reduce((s, it) => s + it.value, 0);
|
||||
return restSum > 0 ? [...top, { name: "Sonstige", value: restSum, __other: true }] : top;
|
||||
}
|
||||
|
||||
/* ---------- Tooltips ---------- */
|
||||
function BarTip({ active, payload }) {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const p = payload[0]?.payload;
|
||||
return (
|
||||
<div className="rounded-lg bg-black/80 text-white/90 border border-white/10 px-3 py-2 text-xs">
|
||||
<div className="font-medium">{p?.name}</div>
|
||||
<div className="opacity-80">{p?.count} Buchungen · {p?.value} Stück · {p?.euro} €</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function PieTip({ active, payload }) {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const p = payload[0];
|
||||
return (
|
||||
<div className="rounded-lg bg-black/80 text-white/90 border border-white/10 px-3 py-2 text-xs">
|
||||
<div className="font-medium">{p?.name}</div>
|
||||
<div className="opacity-80">Anzahl: {p?.value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Avatar-Tick für Y-Achse ---------- */
|
||||
function makeYAvatarTick(avatarByName) {
|
||||
return function YAvatarTick({ x, y, payload }) {
|
||||
const url = avatarByName[payload?.value];
|
||||
const size = 20;
|
||||
const cx = x - 10; // nach links in den Achsenbereich
|
||||
const cy = y - size / 2;
|
||||
return (
|
||||
<g>
|
||||
{url
|
||||
? <image href={url} x={cx - size} y={cy} width={size} height={size} preserveAspectRatio="xMidYMid slice" />
|
||||
: <circle cx={cx - 10} cy={y} r={9} fill="#94a3b8" />}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
export default function StatsPage() {
|
||||
const [meta, setMeta] = useState({ categories: [], last_delivery_at: null, visible_count: 0, hidden_count: 0, now: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [period, setPeriod] = useLocalState("stats.period", "last_delivery");
|
||||
const [view, setView] = useLocalState("stats.view", "chart"); // "chart" | "table"
|
||||
const [category, setCategory] = useLocalState("stats.category", "Alle");
|
||||
|
||||
// Caches
|
||||
const cacheTop = useRef(new Map()); // key `${period}::${category}`
|
||||
const cacheShare = useRef(new Map()); // key `${period}`
|
||||
|
||||
const [topRows, setTopRows] = useState([]);
|
||||
const [share, setShare] = useState({ total: 0, items: [] });
|
||||
|
||||
/* ---- Aufbereitete Daten (Hooks VOR early-returns!) ---- */
|
||||
const topChartData = useMemo(
|
||||
() => topRows
|
||||
.slice(0, 10)
|
||||
.map(r => ({
|
||||
name: r.alias,
|
||||
value: r.amount_sum,
|
||||
avatar_url: resolveAvatar(r.avatar_url),
|
||||
count: r.count,
|
||||
euro: euro(r.revenue_cents),
|
||||
})),
|
||||
[topRows]
|
||||
);
|
||||
|
||||
|
||||
|
||||
const avatarByName = useMemo(() => {
|
||||
const m = Object.create(null);
|
||||
for (const r of topChartData) m[r.name] = r.avatar_url || "";
|
||||
return m;
|
||||
}, [topChartData]);
|
||||
|
||||
const pieDataRaw = useMemo(
|
||||
() => (share.items || []).map((it) => ({ name: it.product_name, value: it.count })),
|
||||
[share]
|
||||
);
|
||||
const pieData = useMemo(() => aggregateTopN(pieDataRaw, 5), [pieDataRaw]);
|
||||
|
||||
/* ---- Daten laden ---- */
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const m = await getStatsMeta();
|
||||
if (!alive) return;
|
||||
setMeta(m || {});
|
||||
const cats = Array.isArray(m?.categories) ? m.categories : [];
|
||||
if (category !== "Alle" && !cats.includes(category)) setCategory("Alle");
|
||||
} catch (e) {
|
||||
if (alive) setErr(e);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const catParam = category === "Alle" ? "all" : category;
|
||||
(async () => {
|
||||
const kTop = `${period}::${catParam}`;
|
||||
if (cacheTop.current.has(kTop)) {
|
||||
setTopRows(cacheTop.current.get(kTop));
|
||||
} else {
|
||||
try {
|
||||
const rows = await apiTopDrinkers({ period, category: catParam, limit: 10 }); // Top 10
|
||||
if (!alive) return;
|
||||
cacheTop.current.set(kTop, rows || []);
|
||||
setTopRows(rows || []);
|
||||
} catch {
|
||||
if (alive) setTopRows([]);
|
||||
}
|
||||
}
|
||||
const kShare = `${period}`;
|
||||
if (cacheShare.current.has(kShare)) {
|
||||
setShare(cacheShare.current.get(kShare));
|
||||
} else {
|
||||
try {
|
||||
const data = await apiProductShare({ period });
|
||||
if (!alive) return;
|
||||
const norm = data && data.items ? data : { total: 0, items: [] };
|
||||
cacheShare.current.set(kShare, norm);
|
||||
setShare(norm);
|
||||
} catch {
|
||||
if (alive) setShare({ total: 0, items: [] });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [period, category]);
|
||||
|
||||
const YAvatarTick = ({ x, y, payload }) => {
|
||||
const name = String(payload?.value ?? "");
|
||||
const url = avatarByName[name] || "";
|
||||
const size = 55;
|
||||
const r = size / 2;
|
||||
|
||||
// stabiler, eindeutiger ID-String (keine Sonderzeichen)
|
||||
const id = "clip-" + name.replace(/\W+/g, "") + "-" + Math.abs([...name].reduce((h,c)=>((h<<5)-h)+c.charCodeAt(0),0));
|
||||
|
||||
// Position: links der Achse
|
||||
const imgX = (x - 12) - size; // Bild links ausrichten
|
||||
const imgY = y - r;
|
||||
const cx = imgX + r; // Kreiszentrum auf Bildmitte
|
||||
const cy = imgY + r;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
<clipPath id={id}>
|
||||
<circle cx={cx} cy={cy} r={r} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{url ? (
|
||||
<image
|
||||
href={url}
|
||||
xlinkHref={url} // Safari-Fallback
|
||||
x={imgX}
|
||||
y={imgY}
|
||||
width={size}
|
||||
height={size}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
clipPath={`url(#${id})`} // <- macht es rund
|
||||
/>
|
||||
) : (
|
||||
<circle cx={cx} cy={cy} r={r} fill="#94a3b8" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
/* ---- Early returns ---- */
|
||||
if (loading) return <div className="text-white">Lade Statistiken…</div>;
|
||||
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
|
||||
|
||||
const categories = ["Alle", ...(meta.categories || [])];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Meta */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-white/80 text-sm flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-2"><FiUsers className="opacity-70" /> sichtbar: <strong>{meta.visible_count}</strong></span>
|
||||
<span className="text-white/50">·</span>
|
||||
<span>ausgeblendet: <strong>{meta.hidden_count}</strong></span>
|
||||
{meta.last_delivery_at && (
|
||||
<>
|
||||
<span className="text-white/50">·</span>
|
||||
<span>letzte Lieferung: <code className="text-white/90">{new Date(meta.last_delivery_at).toLocaleString("de-DE")}</code></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
["last_delivery", "seit letzter Lieferung"],
|
||||
["ytd", "dieses Jahr"],
|
||||
["all", "gesamt"],
|
||||
].map(([k, label]) => (
|
||||
<button key={k}
|
||||
onClick={() => setPeriod(k)}
|
||||
className={`px-3 py-1 rounded-full border transition ${period === k ? "bg-cyan-600 border-cyan-400 text-white" : "bg-transparent border-cyan-400/30 text-white/80 hover:bg-cyan-600/30"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-2 inline-flex rounded-lg overflow-hidden border border-cyan-400/30" role="group" aria-label="Ansicht wählen">
|
||||
<button
|
||||
className={`px-3 py-1 ${view === "chart" ? "bg-cyan-600 text-white" : "bg-transparent text-white/80 hover:bg-cyan-600/30"}`}
|
||||
onClick={() => setView("chart")}
|
||||
title="Grafik">
|
||||
<FiBarChart2 />
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1 ${view === "table" ? "bg-cyan-600 text-white" : "bg-transparent text-white/80 hover:bg-cyan-600/30"}`}
|
||||
onClick={() => setView("table")}
|
||||
title="Tabelle">
|
||||
<FiTable />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kategorie-Tabs */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{categories.map((c) => (
|
||||
<button key={c} onClick={() => setCategory(c)}
|
||||
className={`px-3 py-1 rounded-full border shrink-0 transition ${category === c ? "bg-cyan-600 border-cyan-400 text-white" : "bg-transparent border-cyan-400/30 text-white/80 hover:bg-cyan-600/30"}`}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Inhalt */}
|
||||
{view === "chart" ? (
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
{/* Horizontales Ranking */}
|
||||
<div className="rounded-2xl bg-white/5 border border-white/10 p-5">
|
||||
<h3 className="text-white/90 font-semibold mb-3">Top-Trinker ({category})</h3>
|
||||
{topChartData.length === 0 ? (
|
||||
<div className="text-white/60 text-sm">Keine Daten für den Zeitraum.</div>
|
||||
) : (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="110%">
|
||||
<BarChart layout="vertical" data={topChartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }} >
|
||||
<CartesianGrid stroke="#cbd5e122" />
|
||||
<XAxis type="number" allowDecimals={false} tick={{ fill: "#cbd5e1" }} domain={[0, "dataMax + 1"]} />
|
||||
{/* Avatar statt Name */}
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={<YAvatarTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={72} // vorher 36
|
||||
tickMargin={6}
|
||||
/>
|
||||
<Tooltip content={<BarTip />} />
|
||||
<Bar dataKey="value" isAnimationActive={true} radius={[4,4,4,4]}>
|
||||
<LabelList dataKey="value" position="right" fill="#cbd5e1" />
|
||||
{topChartData.map((d) => <Cell key={d.name} fill={colorFor(d.name)} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Donut + rechte Legende (Top-5 + Sonstige) */}
|
||||
<div className="rounded-2xl bg-white/5 border border-white/10 p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white/90 font-semibold mb-3">Produktverteilung</h3>
|
||||
{(pieData.length === 0 || share.total === 0) ? (
|
||||
<div className="text-white/60 text-sm">Keine Daten für den Zeitraum.</div>
|
||||
) : (
|
||||
<div className="h-64 relative">
|
||||
<ResponsiveContainer width="100%" height="120%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={70}
|
||||
outerRadius={120}
|
||||
isAnimationActive={true}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{pieData.map((s) => (
|
||||
<Cell key={s.name} fill={s.__other ? "#64748b" : colorFor(s.name)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<PieTip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center leading-tight">
|
||||
<div className="text-white/70 text-xs">gesamt</div>
|
||||
<div className="text-white font-semibold">{share.total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-[180px] max-w-[220px]">
|
||||
<ul className="space-y-1">
|
||||
{pieData.map((s) => (
|
||||
<li key={s.name} className="flex items-center gap-2 text-white/80 text-sm">
|
||||
<span className="inline-block w-3 h-3 rounded" style={{ backgroundColor: s.__other ? "#64748b" : colorFor(s.name) }} />
|
||||
<span className="truncate" title={s.name}>{s.name}</span>
|
||||
<span className="ml-auto text-white/60">{s.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Tabellenansicht
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="rounded-2xl bg-white/5 border border-white/10 p-5">
|
||||
<h3 className="text-white/90 font-semibold mb-3">Top-Trinker ({category})</h3>
|
||||
<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">Nutzer</th>
|
||||
<th className="text-right py-2 pr-4">Buchungen</th>
|
||||
<th className="text-right py-2 pr-4">Stück</th>
|
||||
<th className="text-right py-2 pr-4">Umsatz (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topRows.slice(0, 10).map((r) => (
|
||||
<tr key={r.user_id} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{r.avatar_url ? (
|
||||
<img
|
||||
src={resolveAvatar(r.avatar_url)}
|
||||
alt={r.alias}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{r.alias}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">{r.count}</td>
|
||||
<td className="py-2 pr-4 text-right">{r.amount_sum}</td>
|
||||
<td className="py-2 pr-4 text-right font-mono">{euro(r.revenue_cents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white/5 border border-white/10 p-5">
|
||||
<h3 className="text-white/90 font-semibold mb-3">Produktverteilung</h3>
|
||||
<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">Produkt</th>
|
||||
<th className="text-right py-2 pr-4">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pieData.map((p) => (
|
||||
<tr key={p.name} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4">{p.name}</td>
|
||||
<td className="py-2 pr-4 text-right">{p.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
{pieData.length === 0 && (
|
||||
<tr><td className="py-2 text-white/60" colSpan={2}>Keine Daten.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
389
apps/frontend/src/management/pages/TransactionPage.jsx
Normal file
389
apps/frontend/src/management/pages/TransactionPage.jsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCurrentUser,
|
||||
getMyTransactions,
|
||||
getMyTopups,
|
||||
createTopup,
|
||||
} from "../../api";
|
||||
import {
|
||||
FiCreditCard,
|
||||
FiSave,
|
||||
FiCopy,
|
||||
FiCheck,
|
||||
FiExternalLink,
|
||||
FiRefreshCw,
|
||||
} from "react-icons/fi";
|
||||
|
||||
/* ---------- Geld & PayPal ---------- */
|
||||
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
function parseEuroToCents(input) {
|
||||
if (typeof input !== "string") return null;
|
||||
const s = input.trim().replace(/\s/g, "").replace(",", ".");
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!isFinite(n)) return null;
|
||||
const cents = Math.round(n * 100);
|
||||
return cents > 0 ? cents : null;
|
||||
}
|
||||
function genCode5() {
|
||||
return String(Math.floor(Math.random() * 100000)).padStart(5, "0");
|
||||
}
|
||||
function buildPaypalUrl(amountCents, code) {
|
||||
if (!amountCents || amountCents <= 0) return null;
|
||||
const amountEuro = (amountCents / 100).toFixed(2);
|
||||
const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim();
|
||||
const receiver = import.meta.env.VITE_PAYPAL_RECEIVER && String(import.meta.env.VITE_PAYPAL_RECEIVER).trim();
|
||||
if (me) {
|
||||
return `https://www.paypal.me/${encodeURIComponent(me)}/${encodeURIComponent(amountEuro)}?locale.x=de_DE&country.x=DE¬e=${encodeURIComponent("Bacchus Top-Up " + (code || ""))}`;
|
||||
}
|
||||
if (receiver) {
|
||||
const qs = new URLSearchParams({
|
||||
cmd: "_xclick",
|
||||
business: receiver,
|
||||
currency_code: "EUR",
|
||||
amount: amountEuro,
|
||||
item_name: `Bacchus Top-Up ${code || ""}`,
|
||||
no_note: "1",
|
||||
});
|
||||
return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Zeitformat (warum 2h verzug??) ---------- */
|
||||
const fmtBerlin = new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
const toISOish = (s) =>
|
||||
typeof s === "string"
|
||||
? s.replace(" ", "T").replace(/(\.\d{3})\d+([Z+-])/, "$1$2").replace(/([+-]\d{2})$/, "$1:00")
|
||||
: s;
|
||||
function parseTs(s) { const d = new Date(toISOish(s)); return isNaN(d) ? null : d; }
|
||||
const fmtTs = (s) => { const d = parseTs(s); return d ? fmtBerlin.format(d) : "—"; };
|
||||
|
||||
/* ---------- Normalisierung ---------- */
|
||||
function normStatus(s) {
|
||||
if (!s) return "";
|
||||
if (typeof s === "string") return s.toLowerCase();
|
||||
if (typeof s === "object") {
|
||||
const v = s.value ?? s.status ?? s.state ?? s.name;
|
||||
return typeof v === "string" ? v.toLowerCase() : "";
|
||||
}
|
||||
return String(s).toLowerCase();
|
||||
}
|
||||
function normNote(n) {
|
||||
if (n == null) return "—";
|
||||
if (typeof n === "string") return n;
|
||||
if (typeof n === "object") {
|
||||
if (typeof n.text === "string") return n.text;
|
||||
if (typeof n.note === "string") return n.note;
|
||||
try { return JSON.stringify(n); } catch { return String(n); }
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/* ---------- Mapper ---------- */
|
||||
function mapTopup(t) {
|
||||
return {
|
||||
id: t.id ?? t.topup_id ?? `topup-${t.code || Math.random()}`,
|
||||
ts: t.created_at ?? t.timestamp,
|
||||
amount_cents: typeof t.amount_cents === "number" ? t.amount_cents : null,
|
||||
status: normStatus(t.status ?? t.state),
|
||||
note: normNote(t.note ?? t.code),
|
||||
source: "topup",
|
||||
};
|
||||
}
|
||||
function mapAdjustment(tx) {
|
||||
const cents =
|
||||
(typeof tx.amount_cents === "number" && tx.amount_cents) ??
|
||||
(typeof tx.delta_cents === "number" && tx.delta_cents) ?? null;
|
||||
return {
|
||||
id: tx.id ?? tx.tx_id ?? tx.transaction_id ?? `adj-${Math.random()}`,
|
||||
ts: tx.created_at ?? tx.timestamp,
|
||||
amount_cents: cents,
|
||||
status: normStatus(tx.status ?? tx.state),
|
||||
note: normNote(tx.note ?? tx.info),
|
||||
source: "adjustment",
|
||||
};
|
||||
}
|
||||
|
||||
// Saldo-Anpassungen
|
||||
function looksLikeAdjustment(tx) {
|
||||
const type = String(tx.type || "").toLowerCase();
|
||||
if (type === "booking" || type === "topup") return false;
|
||||
if (type.includes("adjust") || type.includes("balance")) return true;
|
||||
|
||||
const hasBalanceFields = tx.new_balance_cents != null || tx.old_balance_cents != null;
|
||||
const hasProductFields = tx.product_id != null || tx.product != null;
|
||||
const hasPriceFields = tx.total_cents != null || tx.price_cents != null;
|
||||
const amount =
|
||||
(typeof tx.amount_cents === "number" && tx.amount_cents) ??
|
||||
(typeof tx.delta_cents === "number" && tx.delta_cents) ?? null;
|
||||
|
||||
if (hasProductFields || hasPriceFields) return false;
|
||||
if (typeof amount === "number" && amount < 0) return false;
|
||||
return hasBalanceFields || typeof amount === "number";
|
||||
}
|
||||
|
||||
/* ---------- Komponente ---------- */
|
||||
export default function TransactionPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [balanceCents, setBalanceCents] = useState(null);
|
||||
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [txLoading, setTxLoading] = useState(true);
|
||||
const [txErr, setTxErr] = useState(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const me = await getCurrentUser();
|
||||
if (!alive) return;
|
||||
setBalanceCents(typeof me?.balance_cents === "number" ? me.balance_cents : null);
|
||||
} catch (e) {
|
||||
if (alive) setErr(e?.message || "Konnte Guthaben nicht laden.");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
reloadTx();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
async function reloadTx() {
|
||||
setTxLoading(true);
|
||||
setTxErr(null);
|
||||
try {
|
||||
const [topups, txs] = await Promise.all([
|
||||
getMyTopups({ limit: 50, offset: 0 }).catch(() => []),
|
||||
getMyTransactions({ limit: 100, offset: 0 }).catch(() => []),
|
||||
]);
|
||||
|
||||
const topupRows = (Array.isArray(topups) ? topups : (topups.items || [])).map(mapTopup);
|
||||
|
||||
const adjustRows = (Array.isArray(txs) ? txs : (txs.items || []))
|
||||
.filter((t) => {
|
||||
const type = String(t.type || "").toLowerCase();
|
||||
if (type === "booking" || type === "topup") return false;
|
||||
return looksLikeAdjustment(t);
|
||||
})
|
||||
.map(mapAdjustment);
|
||||
|
||||
const merged = [...topupRows, ...adjustRows]
|
||||
.filter((r) => r.amount_cents != null)
|
||||
.sort((a, b) => (parseTs(b.ts)?.getTime() ?? 0) - (parseTs(a.ts)?.getTime() ?? 0))
|
||||
.slice(0, 20);
|
||||
|
||||
setRows(merged);
|
||||
} catch (e) {
|
||||
setTxErr(e?.message || "Konnte Guthaben-Transaktionen nicht laden.");
|
||||
} finally {
|
||||
setTxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const readyToSave = amountCents != null && amountCents > 0;
|
||||
const paypalUrl = useMemo(
|
||||
() => (saved ? buildPaypalUrl(amountCents, code) : null),
|
||||
[saved, amountCents, code]
|
||||
);
|
||||
|
||||
async function onSave() {
|
||||
if (!readyToSave) return;
|
||||
const newCode = genCode5();
|
||||
setSaved(true);
|
||||
setCode(newCode);
|
||||
setCopied(false);
|
||||
try {
|
||||
await createTopup(amountCents, newCode); // speichert Top-up + Code
|
||||
await reloadTx();
|
||||
} catch (e) {
|
||||
setTxErr(e?.message || "Top-up konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
async function onCopy() {
|
||||
try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1200); } catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6" style={{ contentVisibility: "auto", containIntrinsicSize: "1000px" }}>
|
||||
<h1 className="text-white/90 text-xl font-semibold mb-4">Transaktion / Aufladen</h1>
|
||||
|
||||
{loading && <div className="text-white/60">Lade…</div>}
|
||||
{err && !loading && <div className="text-red-300">{String(err)}</div>}
|
||||
|
||||
{!loading && !err && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{/* Guthaben */}
|
||||
<Card title="Aktuelles Guthaben" icon={<FiCreditCard className="text-white/80" />}>
|
||||
<div className="text-3xl font-mono">
|
||||
{typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"}
|
||||
</div>
|
||||
<div className="text-white/50 text-xs mt-1">Stand aus deinem Profil</div>
|
||||
</Card>
|
||||
|
||||
{/* Aufladen */}
|
||||
<Card className="md:col-span-2" title="Aufladen">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="block">
|
||||
<div className="text-white/70 text-sm mb-1">Betrag (EUR)</div>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
placeholder="z. B. 10,00"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
className="w-44 px-3 py-2 rounded-xl bg-white/10 border border-white/15 text-white placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="text-white/60 text-sm mb-2">
|
||||
{amountCents ? `= ${euroFmt.format(amountCents / 100)}` : "—"}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!readyToSave}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border disabled:opacity-50 bg-cyan-600 hover:bg-cyan-700 border-cyan-400/40 text-white font-semibold"
|
||||
title="Topup speichern"
|
||||
>
|
||||
<FiSave /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-white/50 text-xs mt-3">
|
||||
Betrag eingeben und „Speichern“ klicken.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Zahlungsdetails */}
|
||||
{saved && (
|
||||
<Card className="md:col-span-3" title="Zahlungsdetails">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button type="button" onClick={onCopy}
|
||||
className="group relative overflow-hidden rounded-xl border border-white/15 bg-white/5 hover:bg-white/10 px-4 py-3"
|
||||
title="Code kopieren">
|
||||
<div className="text-white/60 text-xs mb-1">Dein Code</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-mono tracking-widest">{code}</span>
|
||||
{copied ? <FiCheck className="text-emerald-300" /> : <FiCopy className="text-white/70 group-hover:text-white" />}
|
||||
</div>
|
||||
<div className="text-white/40 text-xs mt-1">Tippen/Klicken zum Kopieren</div>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={paypalUrl || "#"}
|
||||
onClick={(e) => { if (!paypalUrl) e.preventDefault(); }}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-4 py-3 rounded-xl border ${
|
||||
paypalUrl ? "bg-blue-600 hover:bg-blue-700 border-blue-400/40 text-white"
|
||||
: "bg-white/5 border-white/15 text-white/40 cursor-not-allowed"
|
||||
}`}
|
||||
title={paypalUrl ? "Zu PayPal" : "PayPal-Link nicht konfiguriert"}
|
||||
>
|
||||
<FiExternalLink /><span>PayPal</span>
|
||||
</a>
|
||||
|
||||
<div className="text-white/50 text-xs">
|
||||
Betrag: {amountCents ? euroFmt.format(amountCents / 100) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabelle */}
|
||||
<Card className="md:col-span-3" title="Letzte Guthaben-Transaktionen">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-white/60 text-sm">Zeigt die letzten 20 Einträge.</div>
|
||||
<button onClick={reloadTx}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/10 border border-white/15 text-white/90 hover:bg-white/15"
|
||||
title="Neu laden">
|
||||
<FiRefreshCw /> Neu laden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{txLoading ? (
|
||||
<div className="text-white/60">Lade…</div>
|
||||
) : txErr ? (
|
||||
<div className="text-red-300">{txErr}</div>
|
||||
) : (
|
||||
<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">Transaktions-ID</th>
|
||||
<th className="text-left py-2 pr-4">Zeit</th>
|
||||
<th className="text-left py-2 pr-4">Betrag</th>
|
||||
<th className="text-left py-2 pr-4">Status</th>
|
||||
<th className="text-left py-2 pr-4">Notiz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const amount = r.amount_cents != null ? euroFmt.format(r.amount_cents / 100) : "—";
|
||||
const s = r.status || "";
|
||||
const status =
|
||||
s === "approved" || s === "completed" || s === "success" || s === "confirmed"
|
||||
? "genehmigt"
|
||||
: s === "pending" || s === "waiting"
|
||||
? "wartend"
|
||||
: s === "rejected"
|
||||
? "abgelehnt"
|
||||
: (r.status || "—");
|
||||
return (
|
||||
<tr key={`${r.source}-${r.id}`} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4 font-mono">{r.id}</td>
|
||||
<td className="py-2 pr-4 font-mono">{fmtTs(r.ts)}</td>
|
||||
<td className="py-2 pr-4 font-mono">{amount}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{status === "genehmigt" ? (
|
||||
<span className="text-emerald-300">genehmigt</span>
|
||||
) : status === "wartend" ? (
|
||||
<span className="text-amber-300">wartend</span>
|
||||
) : status === "abgelehnt" ? (
|
||||
<span className="text-rose-300">abgelehnt</span>
|
||||
) : (
|
||||
<span className="text-white/70">{status}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{r.note ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!rows.length && (
|
||||
<tr><td className="py-6 pr-4 text-white/60" colSpan={5}>Keine Einträge vorhanden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- UI-Baustein ---------- */
|
||||
function Card({ title, icon, children, className = "" }) {
|
||||
return (
|
||||
<div className={`rounded-2xl bg-white/5 backdrop-blur-md border border-cyan-400/25 shadow-[0_0_0_1px_rgba(34,211,238,0.06)] p-5 ${className}`}>
|
||||
{(title || icon) && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<h2 className="text-white/90 font-semibold">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
981
apps/frontend/src/management/pages/UsersPage.jsx
Normal file
981
apps/frontend/src/management/pages/UsersPage.jsx
Normal file
@@ -0,0 +1,981 @@
|
||||
import React, { useEffect, useMemo, useState, useRef } from "react";
|
||||
import {
|
||||
listUsers,
|
||||
getUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
setUserPin,
|
||||
setUserPassword,
|
||||
adjustUserBalance,
|
||||
listBookings,
|
||||
getProducts
|
||||
} from "../../api";
|
||||
import { FiEdit2 } from "react-icons/fi";
|
||||
|
||||
function cx(...arr) {
|
||||
return arr.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function euro(cents) {
|
||||
if (typeof cents !== "number") return "—";
|
||||
return (cents / 100).toFixed(2) + " €";
|
||||
}
|
||||
|
||||
const defaultParams = {
|
||||
q: "",
|
||||
role: "",
|
||||
active: "",
|
||||
balance_lt: "",
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
sort: "name",
|
||||
order: "asc",
|
||||
};
|
||||
|
||||
function NiceDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Auswählen…",
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
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={`relative ${className}`} style={{ minWidth }}>
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={
|
||||
"w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2 " +
|
||||
"text-left flex items-center justify-between hover:bg-white/10 " +
|
||||
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="absolute z-20 mt-2 w-full rounded-xl border border-white/15 bg-black/80 backdrop-blur p-1 shadow-2xl"
|
||||
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={
|
||||
"w-full text-left px-3 py-2 rounded-lg transition " +
|
||||
(active ? "bg-white/15 text-white" : "text-white/80 hover:bg-white/10")
|
||||
}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
// Query-State
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
// Drawer-State
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailErr, setDetailErr] = useState(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Viewer-State
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [viewerUser, setViewerUser] = useState(null);
|
||||
const [viewerBookings, setViewerBookings] = useState([]);
|
||||
const [viewerLoading, setViewerLoading] = useState(false);
|
||||
const [viewerErr, setViewerErr] = useState(null);
|
||||
|
||||
// Forms
|
||||
const [formBase, setFormBase] = useState({
|
||||
name: "", email: "", alias: "", paypal_email: "",
|
||||
role: "user", is_active: true,
|
||||
});
|
||||
const [formSec, setFormSec] = useState({ pin: "", password: "" });
|
||||
const [formAdj, setFormAdj] = useState({ amount_eur: "", reason: "" });
|
||||
|
||||
// Create modal
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "", email: "", alias: "", paypal_email: "",
|
||||
role: "user", password: "", pin: "",
|
||||
});
|
||||
const [createErr, setCreateErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// --- Daten laden: Liste ---
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true); setErr(null);
|
||||
(async () => {
|
||||
try {
|
||||
const data = await listUsers(cleanParams(params));
|
||||
if (!alive) return;
|
||||
setRows(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
if (alive) setErr(e);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [params.q, params.role, params.active, params.balance_lt, params.limit, params.offset, params.sort, params.order]);
|
||||
|
||||
// --- Daten laden: Bearbeiten ---
|
||||
useEffect(() => {
|
||||
if (!open || !selectedId) return;
|
||||
let alive = true;
|
||||
setDetailLoading(true); setDetailErr(null);
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getUserById(selectedId);
|
||||
if (!alive) return;
|
||||
setDetail(data);
|
||||
setFormBase({
|
||||
name: data.name ?? "",
|
||||
email: data.email ?? "",
|
||||
alias: data.alias ?? "",
|
||||
paypal_email: data.paypal_email ?? "",
|
||||
role: data.role ?? "user",
|
||||
is_active: Boolean(data.is_active),
|
||||
});
|
||||
setFormSec({ pin: "", password: "" });
|
||||
setFormAdj({ amount_eur: "", reason: "" });
|
||||
setDirty(false);
|
||||
} catch (e) {
|
||||
if (alive) setDetailErr(e);
|
||||
} finally {
|
||||
if (alive) setDetailLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [open, selectedId]);
|
||||
|
||||
const totalShown = rows.length;
|
||||
|
||||
// --- Helpers ---
|
||||
function setParam(k, v) {
|
||||
setParams((p) => ({ ...p, [k]: v, ...(k !== "offset" ? { offset: 0 } : null) }));
|
||||
}
|
||||
function cleanParams(p) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(p)) if (v !== "" && v != null) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
function onSort(key) {
|
||||
setParams((p) => {
|
||||
const order = p.sort === key ? (p.order === "asc" ? "desc" : "asc") : "asc";
|
||||
return { ...p, sort: key, order, offset: 0 };
|
||||
});
|
||||
}
|
||||
function openDetail(id) { setSelectedId(id); setOpen(true); }
|
||||
function closeDetail(force=false) {
|
||||
if (!force && dirty && !confirm("Nicht gespeicherte Änderungen verwerfen?")) return;
|
||||
setOpen(false); setSelectedId(null); setDetail(null); setDetailErr(null); setDirty(false);
|
||||
}
|
||||
|
||||
|
||||
// --- Viewer ---
|
||||
async function openViewer(id) {
|
||||
setViewerOpen(true);
|
||||
setViewerLoading(true);
|
||||
setViewerErr(null);
|
||||
setViewerUser(null);
|
||||
setViewerBookings([]);
|
||||
|
||||
try {
|
||||
const user = await getUserById(id);
|
||||
setViewerUser(user);
|
||||
|
||||
const data = await listBookings({ user_id: id, limit: 10, offset: 0 });
|
||||
let list = Array.isArray(data) ? data : (data.items || data.results || data.data || []);
|
||||
|
||||
list = list.filter(b => (b.user_id === id) || (b.user?.id === id));
|
||||
|
||||
const mapped = list.map(b => {
|
||||
const total =
|
||||
(typeof b.total_cents === "number" && b.total_cents) ??
|
||||
((typeof b.price_cents === "number" ? b.price_cents : (b.product?.price_cents ?? 0)) *
|
||||
(typeof b.amount === "number" ? b.amount : 1));
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
created_at: b.created_at || b.timestamp || null, // Zeitfeld ist optional
|
||||
type: b.type || b.kind || "booking",
|
||||
product_name: b.product_name || b.product?.name || `#${b.product_id ?? "?"}`,
|
||||
amount_cents: total != null ? -Math.abs(Number(total)) : null, // Ausgaben negativ
|
||||
};
|
||||
});
|
||||
|
||||
// Neueste zuerst; Fallback über ID
|
||||
mapped.sort((a, b) =>
|
||||
(new Date(b.created_at || 0) - new Date(a.created_at || 0)) ||
|
||||
((b.id ?? 0) - (a.id ?? 0))
|
||||
);
|
||||
|
||||
setViewerBookings(mapped.slice(0, 10));
|
||||
} catch (e) {
|
||||
setViewerErr(e);
|
||||
} finally {
|
||||
setViewerLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function closeViewer() {
|
||||
setViewerOpen(false);
|
||||
setViewerUser(null);
|
||||
setViewerBookings([]);
|
||||
setViewerErr(null);
|
||||
}
|
||||
|
||||
// --- Aktionen ---
|
||||
async function saveBase() {
|
||||
if (!selectedId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: formBase.name,
|
||||
email: formBase.email,
|
||||
alias: formBase.alias,
|
||||
paypal_email: formBase.paypal_email || null,
|
||||
role: formBase.role,
|
||||
is_active: formBase.is_active,
|
||||
};
|
||||
const updated = await updateUser(selectedId, payload);
|
||||
setDetail(updated);
|
||||
setDirty(false);
|
||||
setParams((p) => ({ ...p }));
|
||||
} catch (e) {
|
||||
alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Fehler beim Speichern: ${e.message || e}`);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function saveSecurity() {
|
||||
if (!selectedId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (formSec.pin && formSec.pin.length === 6) await setUserPin(selectedId, formSec.pin);
|
||||
if (formSec.password && formSec.password.length >= 8) await setUserPassword(selectedId, formSec.password);
|
||||
setFormSec({ pin: "", password: "" });
|
||||
alert("Sicherheitsdaten aktualisiert.");
|
||||
} catch (e) {
|
||||
alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Fehler Sicherheit: ${e.message || e}`);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function runAdjustment() {
|
||||
if (!selectedId) return;
|
||||
const cents = Math.round(parseFloat((formAdj.amount_eur || "").replace(",", ".")) * 100);
|
||||
if (!Number.isFinite(cents) || cents === 0) { alert("Betrag ungültig oder 0."); return; }
|
||||
if (!formAdj.reason || formAdj.reason.trim().length < 1) { alert("Bitte Grund angeben."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await adjustUserBalance(selectedId, cents, formAdj.reason.trim());
|
||||
const data = await getUserById(selectedId);
|
||||
setDetail(data);
|
||||
setFormAdj({ amount_eur: "", reason: "" });
|
||||
setDirty(false);
|
||||
setParams((p) => ({ ...p }));
|
||||
} catch (e) {
|
||||
alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Fehler Kontokorrektur: ${e.message || e}`);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function removeUser() {
|
||||
if (!selectedId) return;
|
||||
if (!confirm("Diesen Nutzer wirklich löschen?")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteUser(selectedId);
|
||||
closeDetail(true);
|
||||
setParams((p) => ({ ...p }));
|
||||
} catch (e) {
|
||||
alert(e.status === 403 ? "Aktion verweigert: Management-Login erforderlich." : `Löschen fehlgeschlagen: ${e.message || e}`);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function createNewUser() {
|
||||
const f = createForm;
|
||||
if (!f.name || !f.email || !f.password || !f.pin) { setCreateErr("Name, E-Mail, Passwort und PIN sind Pflicht."); return; }
|
||||
if (f.pin.length !== 6) { setCreateErr("PIN muss genau 6 Zeichen haben."); return; }
|
||||
if (f.password.length < 8) { setCreateErr("Passwort mindestens 8 Zeichen."); return; }
|
||||
setCreateErr(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await createUser({
|
||||
name: f.name, email: f.email,
|
||||
alias: f.alias || null,
|
||||
paypal_email: f.paypal_email || null,
|
||||
role: f.role || "user",
|
||||
password: f.password, pin: f.pin,
|
||||
});
|
||||
setCreating(false);
|
||||
setCreateForm({ name:"", email:"", alias:"", paypal_email:"", role:"user", password:"", pin:"" });
|
||||
setParams((p) => ({ ...p }));
|
||||
} catch (e) {
|
||||
setCreateErr(e.status === 403 ? "Management-Login erforderlich." : (e.message || String(e)));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const sortIcon = useMemo(() => {
|
||||
return (key) => params.sort === key ? (
|
||||
<span className="ml-1 text-white/50">{params.order === "asc" ? "▲" : "▼"}</span>
|
||||
) : null;
|
||||
}, [params.sort, params.order]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Gesamtkasten mit integrierter Toolbar + Tabelle + Pagination */}
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20">
|
||||
{/* Toolbar oben im Kasten */}
|
||||
<div className="px-4 py-3 border-b border-white/10 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-white/90 font-semibold">Nutzerverwaltung</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Suche */}
|
||||
<div className="relative">
|
||||
<input
|
||||
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white/90 placeholder-white/40 focus:outline-none focus:border-cyan-400/40"
|
||||
placeholder="Suche (Name, E-Mail, Alias)"
|
||||
value={params.q}
|
||||
onChange={(e) => setParam("q", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Rollen-Filter */}
|
||||
<NiceDropdown
|
||||
value={params.role}
|
||||
onChange={(v) => setParam("role", v)}
|
||||
placeholder="Alle Rollen"
|
||||
options={[
|
||||
{ value: "", label: "Alle Rollen" },
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "manager", label: "Manager" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Aktiv-Filter */}
|
||||
<NiceDropdown
|
||||
value={params.active}
|
||||
onChange={(v) => setParam("active", v)}
|
||||
placeholder="Aktiv/Inaktiv"
|
||||
options={[
|
||||
{ value: "", label: "Alle" },
|
||||
{ value: "true", label: "Aktiv" },
|
||||
{ value: "false", label: "Inaktiv" },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* Quickfilter: Kontostand < 0 */}
|
||||
<button
|
||||
className={cx(
|
||||
"px-3 py-2 rounded-xl border",
|
||||
params.balance_lt === "0"
|
||||
? "bg-amber-500/20 border-amber-400/40 text-amber-100"
|
||||
: "bg-white/5 border-white/10 text-white/80 hover:bg-white/10"
|
||||
)}
|
||||
onClick={() => setParam("balance_lt", params.balance_lt === "0" ? "" : "0")}
|
||||
title="Kontostand < 0"
|
||||
>
|
||||
< 0 €
|
||||
</button>
|
||||
|
||||
{/* Nutzer anlegen */}
|
||||
<button
|
||||
className="px-3 py-2 rounded-xl bg-emerald-500/20 text-emerald-100 border border-emerald-400/30 hover:bg-emerald-500/30"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
Nutzer anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-white/60">
|
||||
<tr>
|
||||
<Th onClick={() => onSort("name")} active={params.sort === "name"} order={params.order}>Name</Th>
|
||||
<Th onClick={() => onSort("email")} active={params.sort === "email"} order={params.order}>E-Mail</Th>
|
||||
<Th>Alias</Th>
|
||||
<Th onClick={() => onSort("role")} active={params.sort === "role"} order={params.order}>Rolle</Th>
|
||||
<Th onClick={() => onSort("balance_cents")} active={params.sort === "balance_cents"} order={params.order}>Kontostand</Th>
|
||||
<Th onClick={() => onSort("is_active")} active={params.sort === "is_active"} order={params.order}>Status</Th>
|
||||
<Th>Aktionen</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white/85">
|
||||
{loading ? (
|
||||
<tr><td className="p-4 text-white" colSpan={7}>Lade…</td></tr>
|
||||
) : err ? (
|
||||
<tr><td className="p-4 text-red-300" colSpan={7}>Fehler: {String(err.message || err)}</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td className="p-4 text-white/70" colSpan={7}>Keine Nutzer gefunden.</td></tr>
|
||||
) : (
|
||||
rows.map((u) => (
|
||||
<tr key={u.id} className="border-t border-white/10">
|
||||
<td className="py-2 px-3">{u.name}</td>
|
||||
<td className="py-2 px-3">{u.email}</td>
|
||||
<td className="py-2 px-3">{u.alias || "—"}</td>
|
||||
<td className="py-2 px-3">{u.role}</td>
|
||||
<td className={cx(
|
||||
"py-2 px-3 font-mono",
|
||||
typeof u.balance_cents === "number" && u.balance_cents < 0
|
||||
? "text-red-300"
|
||||
: u.balance_cents < 500
|
||||
? "text-amber-200"
|
||||
: "text-white/90"
|
||||
)}>
|
||||
{euro(u.balance_cents)}
|
||||
</td>
|
||||
<td className="py-2 px-3">{u.is_active ? "aktiv" : "inaktiv"}</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Anzeigen (Auge) */}
|
||||
<button
|
||||
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-white/80"
|
||||
title="Details & Buchungen ansehen"
|
||||
onClick={() => openViewer(u.id)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="pointer-events-none">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" stroke="currentColor" strokeWidth="1.8"/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Bearbeiten (Stift / FiEdit2) */}
|
||||
<button
|
||||
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-white/80"
|
||||
title="Bearbeiten"
|
||||
onClick={() => openDetail(u.id)}
|
||||
>
|
||||
<FiEdit2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination im Kasten */}
|
||||
<div className="flex items-center justify-between px-4 py-3 text-white/70 border-t border-white/10">
|
||||
<div>Zeige {totalShown} Einträge</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white/10 border border-white/10 disabled:opacity-40"
|
||||
disabled={params.offset <= 0}
|
||||
onClick={() => setParam("offset", Math.max(0, params.offset - params.limit))}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white/10 border border-white/10 disabled:opacity-40"
|
||||
disabled={rows.length < params.limit}
|
||||
onClick={() => setParam("offset", params.offset + params.limit)}
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Name"
|
||||
value={formBase.name}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, name: v })); setDirty(true); }}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
|
||||
{/* Drawer (Bearbeiten) – fixed */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40">
|
||||
{/* 60% transparenter Hintergrund */}
|
||||
<div className="fixed inset-0 bg-black/60" onClick={() => closeDetail(false)} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed right-0 top-0 h-full w-full sm:w-[560px] bg-black border-l border-white/10 shadow-xl overflow-y-auto z-50">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-white/90 font-semibold">Nutzer bearbeiten</h3>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-600 text-gray-100"
|
||||
onClick={() => closeDetail(false)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inhalt */}
|
||||
<div className="p-5 space-y-6">
|
||||
{detailLoading ? (
|
||||
<div className="text-white">Lade Details…</div>
|
||||
) : detailErr ? (
|
||||
<div className="text-red-300">Fehler: {String(detailErr.message || detailErr)}</div>
|
||||
) : !detail ? (
|
||||
<div className="text-white/70">Keine Daten.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stammdaten */}
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-white/85 font-semibold">Daten</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
value={formBase.name}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, name: v })); setDirty(true); }}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
value={formBase.email}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, email: v })); setDirty(true); }}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
<TextField
|
||||
label="Alias"
|
||||
value={formBase.alias}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, alias: v })); setDirty(true); }}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
<TextField
|
||||
label="PayPal"
|
||||
value={formBase.paypal_email}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, paypal_email: v })); setDirty(true); }}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
|
||||
{/* Rolle */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-white/60">Rolle</div>
|
||||
<NiceDropdown
|
||||
value={formBase.role}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, role: v })); setDirty(true); }}
|
||||
placeholder="Rolle wählen"
|
||||
options={[
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "manager", label: "Manager" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
]}
|
||||
minWidth={200}
|
||||
buttonClassName="bg-gray-800 text-gray-100 border-gray-600 hover:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-white/60">Status</div>
|
||||
<NiceDropdown
|
||||
value={formBase.is_active ? "true" : "false"}
|
||||
onChange={(v) => { setFormBase(f => ({ ...f, is_active: v === "true" })); setDirty(true); }}
|
||||
placeholder="Status wählen"
|
||||
options={[
|
||||
{ value: "true", label: "Aktiv" },
|
||||
{ value: "false", label: "Inaktiv" },
|
||||
]}
|
||||
minWidth={200}
|
||||
buttonClassName="bg-gray-800 text-gray-100 border-gray-600 hover:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-emerald-500/20 text-emerald-100 border border-emerald-400/30 hover:bg-emerald-500/30 disabled:opacity-50"
|
||||
onClick={saveBase}
|
||||
disabled={busy || !dirty}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-gray-800 border border-gray-600 hover:bg-gray-700 text-gray-100 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setFormBase({
|
||||
name: detail.name ?? "",
|
||||
email: detail.email ?? "",
|
||||
alias: detail.alias ?? "",
|
||||
paypal_email: detail.paypal_email ?? "",
|
||||
role: detail.role ?? "user",
|
||||
is_active: Boolean(detail.is_active),
|
||||
});
|
||||
setDirty(false);
|
||||
}}
|
||||
disabled={busy || !dirty}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sicherheit */}
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-white/85 font-semibold">Sicherheit</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Neue PIN (6)"
|
||||
value={formSec.pin}
|
||||
onChange={(v) => setFormSec(s => ({ ...s, pin: v }))}
|
||||
maxLength={6}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
<TextField
|
||||
label="Neues Passwort (≥8)"
|
||||
value={formSec.password}
|
||||
onChange={(v) => setFormSec(s => ({ ...s, password: v }))}
|
||||
type="password"
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-cyan-500/20 text-cyan-100 border border-cyan-400/30 hover:bg-cyan-500/30 disabled:opacity-50"
|
||||
onClick={saveSecurity}
|
||||
disabled={busy || (!formSec.pin && !formSec.password)}
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Konto */}
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-white/85 font-semibold">Konto</h4>
|
||||
<div className="text-white/70">
|
||||
Aktueller Kontostand: <span className="font-mono">{euro(detail.balance_cents)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Betrag (€, +/-)"
|
||||
value={formAdj.amount_eur}
|
||||
onChange={(v) => setFormAdj(a => ({ ...a, amount_eur: v }))}
|
||||
placeholder="+10.00"
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
<TextField
|
||||
label="Grund"
|
||||
value={formAdj.reason}
|
||||
onChange={(v) => setFormAdj(a => ({ ...a, reason: v }))}
|
||||
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-amber-500/20 text-amber-100 border border-amber-400/30 hover:bg-amber-500/30 disabled:opacity-50"
|
||||
onClick={runAdjustment}
|
||||
disabled={busy}
|
||||
>
|
||||
Korrektur verbuchen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Gefahrzone */}
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-red-300 font-semibold"></h4>
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-red-500/20 text-red-100 border border-red-400/30 hover:bg-red-500/30 disabled:opacity-50"
|
||||
onClick={removeUser}
|
||||
disabled={busy}
|
||||
>
|
||||
Nutzer löschen
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Viewer Modal (Read-Only) */}
|
||||
{viewerOpen && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="fixed inset-0 bg-black/60" onClick={closeViewer} />
|
||||
<div className="fixed left-1/2 top-10 -translate-x-1/2 w-full max-w-4xl bg-slate-900 border border-white/10 rounded-2xl shadow-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-white/90 font-semibold">Nutzerdaten & Buchungen</h3>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-white/80"
|
||||
onClick={closeViewer}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewerLoading ? (
|
||||
<div className="text-white">Lade…</div>
|
||||
) : viewerErr ? (
|
||||
<div className="text-red-300">Fehler: {String(viewerErr.message || viewerErr)}</div>
|
||||
) : !viewerUser ? (
|
||||
<div className="text-white/70">Keine Daten.</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Stammdaten */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<ReadField label="Name" value={viewerUser.name} />
|
||||
<ReadField label="E-Mail" value={viewerUser.email} />
|
||||
<ReadField label="Alias" value={viewerUser.alias || "—"} />
|
||||
<ReadField label="PayPal" value={viewerUser.paypal_email || "—"} />
|
||||
<ReadField label="Rolle" value={viewerUser.role} />
|
||||
<ReadField label="Status" value={viewerUser.is_active ? "aktiv" : "inaktiv"} />
|
||||
<ReadField label="Kontostand" value={euro(viewerUser.balance_cents)} />
|
||||
<ReadField label="Erstellt" value={formatDate(viewerUser.created_at)} />
|
||||
</section>
|
||||
|
||||
{/* Buchungen */}
|
||||
<section>
|
||||
<div className="text-white/85 font-semibold mb-2">Letzte Buchungen</div>
|
||||
<div className="rounded-xl bg-white/5 border border-white/10 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-white/60">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-3">Zeit</th>
|
||||
<th className="text-left py-2 px-3">Typ</th>
|
||||
<th className="text-left py-2 px-3">Produkt</th>
|
||||
<th className="text-right py-2 px-3">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white/85">
|
||||
{Array.isArray(viewerBookings) && viewerBookings.length > 0 ? viewerBookings.map((b) => (
|
||||
<tr key={b.id} className="border-t border-white/10">
|
||||
<td className="py-2 px-3">{formatDate(b.created_at)}</td>
|
||||
<td className="py-2 px-3">{b.type || b.kind || "—"}</td>
|
||||
<td className="py-2 px-3">{b.product_name || (b.product_id != null ? `#${b.product_id}` : "—")}</td>
|
||||
<td className={cx("py-2 px-3 text-right font-mono", (b.amount_cents ?? 0) < 0 ? "text-red-300" : "text-white/90")}>
|
||||
{typeof b.amount_cents === "number" ? euro(b.amount_cents) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td className="py-3 px-3 text-white/70" colSpan={4}>Keine Buchungen gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<div className="fixed inset-0 bg-black/60" onClick={() => setCreating(false)} />
|
||||
<div className="fixed left-1/2 top-24 -translate-x-1/2 w-full max-w-xl bg-slate-900 border border-white/10 rounded-2xl shadow-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-white/90 font-semibold">Nutzer anlegen</h3>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-white/80"
|
||||
onClick={() => setCreating(false)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<TextField label="Name" value={createForm.name} onChange={(v) => setCreateForm(f => ({ ...f, name: v }))} />
|
||||
<TextField label="E-Mail" value={createForm.email} onChange={(v) => setCreateForm(f => ({ ...f, email: v }))} />
|
||||
<TextField label="Alias" value={createForm.alias} onChange={(v) => setCreateForm(f => ({ ...f, alias: v }))} />
|
||||
<TextField label="PayPal" value={createForm.paypal_email} onChange={(v) => setCreateForm(f => ({ ...f, paypal_email: v }))} />
|
||||
<SelectField
|
||||
label="Rolle"
|
||||
value={createForm.role}
|
||||
onChange={(v) => setCreateForm(f => ({ ...f, role: v }))}
|
||||
options={[
|
||||
{ value: "user", label: "user" },
|
||||
{ value: "manager", label: "manager" },
|
||||
{ value: "admin", label: "admin" },
|
||||
]}
|
||||
/>
|
||||
<TextField label="Passwort (≥8)" type="password" value={createForm.password} onChange={(v) => setCreateForm(f => ({ ...f, password: v }))} />
|
||||
<TextField label="PIN (6)" value={createForm.pin} onChange={(v) => setCreateForm(f => ({ ...f, pin: v }))} maxLength={6} />
|
||||
</div>
|
||||
{createErr && <div className="text-red-300 text-sm mt-2">{createErr}</div>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-white/10 border border-white/10 hover:bg-white/15 text-white/80"
|
||||
onClick={() => setCreating(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 rounded-xl bg-emerald-500/20 text-emerald-100 border border-emerald-400/30 hover:bg-emerald-500/30 disabled:opacity-50"
|
||||
onClick={createNewUser}
|
||||
disabled={busy}
|
||||
>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- kleine UI-Bausteine ---------- */
|
||||
|
||||
function Th({ children, onClick, active, order }) {
|
||||
return (
|
||||
<th
|
||||
className={cx("text-left py-2 px-3 select-none", onClick ? "cursor-pointer hover:text-white/80" : "")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
{children}
|
||||
{active ? <span className="ml-1 text-white/50">{order === "asc" ? "▲" : "▼"}</span> : null}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, type = "text", maxLength, placeholder }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-white/60 text-xs mb-1">{label}</div>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded-xl bg-gre/5 border border-white/10 text-white/90 placeholder-white/30 focus:outline-none focus:border-cyan-400/40"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
type={type}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ label, value, onChange, options }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-white/60 text-xs mb-1">{label}</div>
|
||||
<select
|
||||
style={{ colorScheme: "white" }}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-800 text-slate-100 border border-white/10 focus:outline-none border-3 focus:border-cyan-200/40"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadField({ label, value }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-white/60 text-xs mb-1">{label}</div>
|
||||
<div className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white/90">{value ?? "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(s) {
|
||||
if (!s) return "—";
|
||||
try {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString();
|
||||
} catch { return String(s); }
|
||||
}
|
||||
|
||||
function SimpleDropdown({ value, onChange, options, placeholder }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-40 flex items-center justify-between px-3 py-2 rounded-lg
|
||||
bg-white/10 border border-white/20 text-white"
|
||||
>
|
||||
<span>{options.find(o => o.value === value)?.label || placeholder}</span>
|
||||
<span className={`transition-transform ${open ? "rotate-180" : ""}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-2 w-40 rounded-lg bg-slate-900 border border-white/15 shadow-lg">
|
||||
<ul className="max-h-60 overflow-auto">
|
||||
{options.map(opt => (
|
||||
<li key={opt.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-white/10 ${
|
||||
value === opt.value ? "bg-cyan-600/30" : ""
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user