initial commit

This commit is contained in:
2025-08-28 14:24:06 +00:00
commit 2105518e85
53 changed files with 11236 additions and 0 deletions

71
apps/frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,71 @@
import React from "react";
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
import PinLoginPage from "./components/PinLoginPage";
import Order from "./components/Order";
import ManagementLoginPage from "./components/ManagementLoginPage";
import ManagementRoutes from "./management/ManagementRoutes";
import { getCurrentUser } from "./api";
function useAuth() {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
React.useEffect(() => {
let active = true;
(async () => {
try {
const u = await getCurrentUser();
if (active) setUser(u);
} catch {
if (active) setUser(null);
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, []);
return { user, loading, error, setUser };
}
function LoadingScreen({ label = "Lade..." }) {
return <div className="flex items-center justify-center h-screen bg-[#0a0a0a] text-white">{label}</div>;
}
function RequireAuth({ children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <LoadingScreen />;
if (!user) return <Navigate to="/" replace state={{ from: location }} />;
return React.cloneElement(children, { user });
}
function RequireRole({ roles = ["admin", "manager"], children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <LoadingScreen />;
if (!user) return <Navigate to="/management-login" replace state={{ from: location }} />;
const role = (user.role || "user").toLowerCase();
if (!roles.map((r) => r.toLowerCase()).includes(role)) return <Navigate to="/" replace />;
return React.cloneElement(children, { currentUser: user });
}
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<PinLoginPage />} />
<Route path="/management-login" element={<ManagementLoginPage />} />
<Route path="/order" element={<RequireAuth><Order /></RequireAuth>} />
<Route
path="/management/*"
element={
<RequireRole roles={["admin", "manager", "user"]}>
<ManagementRoutes />
</RequireRole>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
);
}

457
apps/frontend/src/api.js Normal file
View File

@@ -0,0 +1,457 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
/* ===================== Cookies & CSRF ===================== */
const API = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
function getCookie(name) {
const re = new RegExp(
"(?:^|; )" +
name.replace(/([.$?*|{}()[\]\\\/^])/g, "\\$1") +
"=([^;]*)"
);
const m = document.cookie.match(re);
return m ? decodeURIComponent(m[1]) : null;
}
// Holt CSRF
async function getCsrfMaybe() {
const fromCookie = getCookie("bacchus_csrf");
if (fromCookie) return fromCookie;
const res = await fetch(`${API_BASE}/auth/csrf`, {
method: "GET",
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error("CSRF holen fehlgeschlagen");
const token = getCookie("bacchus_csrf");
if (!token) throw new Error("CSRF-Cookie fehlt");
return token;
}
//
export async function getCsrfToken() {
return getCsrfMaybe();
}
/* ===================== Request-Helper ===================== */
export async function request(path, options = {}) {
const url = `${API_BASE}${path}`;
const method = (options.method || "GET").toUpperCase();
const headers = {
Accept: "application/json",
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
};
headers["X-CSRF-Token"] = await getCsrfMaybe();
const res = await fetch(url, {
...options,
method,
headers,
credentials: "include",
});
if (res.status === 204) return null;
const text = await res.text().catch(() => "");
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
/* Hi */
}
}
if (!res.ok) {
const msg = (data && (data.detail || data.message)) || text || `Fehler ${res.status}`;
const err = new Error(msg);
err.status = res.status;
err.response = res;
throw err;
}
return data;
}
export async function getJson(path, params) {
const qs =
params && typeof params === "object"
? "?" +
Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join("&")
: "";
return request(`${path}${qs}`, { method: "GET" });
}
/* ===================== Auth / Session ===================== */
export function loginPin(pin) {
return request("/auth/pin-login", {
method: "POST",
body: JSON.stringify({ pin }),
});
}
export const loginWithPin = loginPin;
export function loginManagement(email, password) {
return request("/auth/management-login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function getCurrentUser() {
return request("/auth/me");
}
export function getCapabilities() {
return request("/auth/capabilities");
}
/* ===================== Dashboard ===================== */
export function getStatsSummary() {
return request("/stats/summary");
}
/* ===================== Users & Favorites ===================== */
// Parametrische Liste (Suche/Filter/Sort/Paging)
export function listUsers(params = {}) {
return getJson("/users/", params); // trailing slash beibehalten
}
export function getUsers() {
return listUsers();
}
export function getUserById(userId) {
return request(`/users/${userId}`);
}
export function createUser(payload) {
return request("/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(userId, patch) {
return request(`/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
export function deleteUser(userId) {
return request(`/users/${userId}`, { method: "DELETE" });
}
// Sicherheitsaktionen (Admin/Manager)
export function setUserPin(userId, pin) {
return request(`/users/${userId}/set-pin`, {
method: "POST",
body: JSON.stringify({ pin }),
});
}
export function setUserPassword(userId, password) {
return request(`/users/${userId}/set-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function adjustUserBalance(userId, amount_cents, reason) {
return request(`/users/${userId}/adjust-balance`, {
method: "POST",
body: JSON.stringify({ amount_cents, reason }),
});
}
export function getFavorites(userId) {
return request(`/users/${userId}/favorites`);
}
export function updateFavorites(userId, favorites) {
const arr = Array.isArray(favorites) ? favorites.map((n) => (Number(n) | 0)) : [];
return request(`/users/${userId}/favorites`, {
method: "PATCH",
body: JSON.stringify(arr),
});
}
export function replaceFavorites(userId, favorites) {
const arr = Array.isArray(favorites) ? favorites.map((n) => (Number(n) | 0)) : [];
return request(`/users/${userId}/favorites`, {
method: "PUT",
body: JSON.stringify(arr),
});
}
/* ===================== Profile / Avatar ===================== */
export async function uploadAvatar(file) {
const form = new FormData();
form.append("file", file);
const res = await fetch(`${API_BASE}/profile/avatar`, {
method: "POST",
body: form,
credentials: "include",
headers: { "X-CSRF-Token": await getCsrfMaybe() }, // multipart: kein Content-Type setzen
});
if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`);
return res.json();
}
export function updateOwnProfile(patch) {
return request("/profile/me", {
method: "PUT",
body: JSON.stringify(patch),
});
}
export function requestNewPin() {
return request("/profile/request-new-pin", { method: "POST" });
}
/* ===================== Products ===================== */
export function getProducts() {
return request("/products/"); // trailing slash vermeidet 307
}
export function getProduct(productId) {
return request(`/products/${productId}`);
}
export function createProduct(payload) {
return request("/products/", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateProduct(productId, patch) {
return request(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify(patch),
});
}
export function deleteProduct(productId) {
return request(`/products/${productId}`, { method: "DELETE" });
}
/* ===================== Bookings ===================== */
export function createBooking(userId, productId, amount, totalCents, comment = null) {
const payload = {
user_id: Number(userId),
product_id: Number(productId),
amount: Number(amount),
total_cents: Number(totalCents),
...(comment ? { comment } : {}),
};
return request("/bookings/", { method: "POST", body: JSON.stringify(payload) });
}
export function getMyBookings({ limit = 100, offset = 0 } = {}) {
return getJson("/bookings/me", { limit, offset });
}
export function listBookings({ user_id, limit = 10, offset = 0 } = {}) {
return getJson("/bookings/", { user_id, limit, offset });
}
/* ===================== Deliveries (Manager/Admin) ===================== */
export function getDeliveries() {
return request("/deliveries/");
}
export function getDelivery(id) {
return request(`/deliveries/${id}`);
}
export function createDelivery(payload) {
return request("/deliveries/", { method: "POST", body: JSON.stringify(payload) });
}
export function deleteDelivery(id) {
return request(`/deliveries/${id}`, { method: "DELETE" });
}
/* ===================== Topups (Manager/Admin) ===================== */
export function patchTopupStatus(topupId, status) {
return request(`/topups/${topupId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
export function getMyTopups({ limit = 100, offset = 0 } = {}) {
return getJson("/topups/me", { limit, offset });
}
/* ===================== Stats ===================== */
export function getConsumptionPerUser() {
return request("/stats/consumption-per-user");
}
export function getConsumptionPerProduct() {
return request("/stats/consumption-per-product");
}
export function getMonthlyRanking({ year, month, limit = 10 } = {}) {
return getJson("/stats/monthly-ranking", { year, month, limit });
}
/* ===================== Audit / Transactions ===================== */
// Audit-Logs (Admin)
export function getAuditLogs({ limit = 100, offset = 0, user_id, action, q, date_from, date_to } = {}) {
return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to });
}
// Eigene Transaktionen
export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
return getJson("/transactions/me", { limit, offset });
}
// Admin: alle Transaktionen
export function getTransactionsAdmin({ limit = 100, offset = 0 } = {}) {
return getJson("/transactions", { limit, offset });
}
/* ===================== Categories (Manager/Admin) ===================== */
// Liste aller Kategorien (Array<string>)
export function getCategories() {
return request("/categories/"); // trailing slash wie bei /products/
}
// Kategorie umbenennen: alle Produkte mit old_name -> new_name
export function renameCategory(oldName, newName) {
if (!oldName || !newName) throw new Error("oldName und newName sind erforderlich");
const qs = `?old_name=${encodeURIComponent(oldName)}&new_name=${encodeURIComponent(newName)}`;
return request(`/categories/rename${qs}`, { method: "PUT" });
}
// Kategorie löschen, optional umhängen (reassign_to)
// Wenn reassignTo null/undefined ist, wird category auf NULL gesetzt
export function deleteCategory(name, reassignTo = null) {
if (!name) throw new Error("name ist erforderlich");
const params = new URLSearchParams({ name });
if (reassignTo) params.set("reassign_to", reassignTo);
return request(`/categories/?${params.toString()}`, { method: "DELETE" });
}
/* ================== Transaktionen Tracker ==============*/
export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) {
return getJson("/ledger/me", { limit, offset, types });
}
export function createTopup(amount_cents, note = null) {
const body = { amount_cents: Number(amount_cents) };
if (note) body.note = note;
return request("/topups/", {
method: "POST",
body: JSON.stringify(body),
});
}
// ===================== NEU: Stats (öffentlich, aber auth-pflichtig) =====================
export const getStatsMeta = () => request("/stats/meta");
export function getTopDrinkers({ period = "last_delivery", category = "all", limit = 5, tz = "Europe/Berlin" } = {}) {
return getJson("/stats/top-drinkers", { period, category, limit, tz });
}
export function getProductShare({ period = "last_delivery", tz = "Europe/Berlin" } = {}) {
return getJson("/stats/product-share", { period, tz });
}
let CSRF = { token: null, header: "X-CSRF-Token", fetchedAt: 0 };
async function ensureCsrf() {
if (CSRF.token && Date.now() - CSRF.fetchedAt < 5 * 60 * 1000) return CSRF;
const res = await fetch(API + "/auth/csrf", { credentials: "include" });
let data = {};
try { data = await res.json(); } catch {}
CSRF.token =
data?.token || data?.csrf || data?.csrf_token || data?.value || null;
CSRF.header =
data?.header_name || data?.header || CSRF.header; // Server kann den Headernamen mitliefern
CSRF.fetchedAt = Date.now();
if (!CSRF.token) throw new Error("CSRF token missing from /auth/csrf");
return CSRF;
}
async function req(path, opts = {}) {
const method = (opts.method || "GET").toUpperCase();
const isMutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (isMutating) {
const { token, header } = await ensureCsrf(); // <- WICHTIG
headers[header] = token;
}
const res = await fetch(API + path, {
credentials: "include",
headers,
...opts,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
return res.status === 204 ? null : res.json();
}
export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) {
const qs = new URLSearchParams({ limit, offset });
if (status_filter) qs.set("status_filter", status_filter);
if (user_id) qs.set("user_id", user_id);
return req(`/topups?${qs.toString()}`);
}
export async function createTopupAdmin({ user_id, amount_cents, note = "" }) {
return req(`/topups`, {
method: "POST",
body: JSON.stringify({ user_id, amount_cents, note }),
});
}
export async function updateTopupStatus(id, status) {
return req(`/topups/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
const lim = Math.min(Number(limit) || 200, 200);
return req(`/users?limit=${lim}&offset=${offset}`);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { FaPlus, FaMinus } from 'react-icons/fa';
export default function CartItem({ item, onIncrement, onDecrement }) {
const product = item?.product || {};
return (
<div className="flex items-center justify-between bg-gray-200 rounded-lg p-2 mb-2 shadow">
<div className="flex flex-col">
<p className="font-semibold text-gray-800 max-w-[14rem] truncate">
{product.name || 'Unbekanntes Produkt'}
</p>
<p className="text-sm text-gray-600">
{((product.price_cents ?? 0) / 100).toFixed(2).replace('.', ',')}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onDecrement(product.id)}
className="bg-red-600 hover:bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded"
aria-label="Menge verringern"
type="button"
>
<FaMinus className="text-lg" />
</button>
<span className="font-semibold text-xl text-gray-800 min-w-6 text-center">
{item.quantity}
</span>
<button
onClick={() => onIncrement(product.id)}
className="bg-green-600 hover:bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded"
aria-label="Menge erhöhen"
type="button"
>
<FaPlus className="text-lg" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { loginManagement } from '../api';
export default function ManagementLoginPage() {
const [email, setEmail] = useState('');
const [pw, setPw] = useState('');
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const navigate = useNavigate();
const canSubmit = email.trim().length > 3 && pw.length >= 6 && !loading;
const onSubmit = async (e) => {
e.preventDefault();
if (!canSubmit) return;
setLoading(true);
setErr('');
try {
await loginManagement(email.trim(), pw);
// Session-Cookie ist nun gesetzt -> ins Management-Dashboard
navigate('/management', { replace: true });
} catch (error) {
setErr(error.message || 'Login fehlgeschlagen');
} finally {
setLoading(false);
}
};
return (
<div className="relative min-h-screen w-full flex items-start md:items-center justify-center
bg-gradient-to-br from-[#000F2E] via-[#00246C] to-[#002D86] p-6 md:p-10"
>
<div className="w-full max-w-[640px]">
{/* Kopf mit SVGs aus /public */}
<div className="flex items-center justify-center gap-6 mb-8 select-none">
<img src="/logo_grape.svg" alt="Trauben" className="w-12 h-12 md:w-14 md:h-14" draggable={false} />
<h1 className="text-4xl md:text-5xl font-extrabold tracking-[0.25em] text-white">BACCHUS</h1>
<img src="/logo_glass.svg" alt="Glas" className="w-12 h-12 md:w-14 md:h-14" draggable={false} />
</div>
{/* Karte */}
<div className="rounded-2xl bg-[#00136C]/90 border border-[#2B2B45] ring-1 ring-[#0c2a36]/40 shadow-2xl p-6 md:p-8">
<h2 className="text-white text-2xl font-bold mb-6 text-center">Management-Login</h2>
<form onSubmit={onSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="block text-sm font-semibold text-white/80 mb-2">
E-Mail
</label>
<input
id="email"
type="email"
autoComplete="username"
className="w-full rounded-xl bg-[#22243a] border border-[#2B2B45] ring-1 ring-[#0c2a36]/40
px-4 py-3 text-white placeholder-white/40 outline-none
focus:border-[#3e4b7b] focus:ring-[#3e4b7b]"
placeholder="admin@example.org"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
</div>
<div>
<label htmlFor="pw" className="block text-sm font-semibold text-white/80 mb-2">
Passwort
</label>
<input
id="pw"
type="password"
autoComplete="current-password"
className="w-full rounded-xl bg-[#22243a] border border-[#2B2B45] ring-1 ring-[#0c2a36]/40
px-4 py-3 text-white placeholder-white/40 outline-none
focus:border-[#3e4b7b] focus:ring-[#3e4b7b]"
placeholder="••••••••"
value={pw}
onChange={(e) => setPw(e.target.value)}
disabled={loading}
/>
</div>
{err && (
<div className="text-red-400 text-sm font-medium">
{err}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded-xl py-3 text-white text-lg font-bold
bg-[#0f6a37] hover:bg-[#117a3f] active:translate-y-[1px]
border border-[#0a3b20] ring-1 ring-[#0a3b20]/50
disabled:opacity-60 transition"
>
{loading ? 'Einloggen…' : 'Einloggen'}
</button>
</form>
{/* Sekundäre Aktionen */}
<div className="mt-6 flex items-center text-white/70 text-sm">
<Link to="/" className="hover:text-white underline underline-offset-4">
Zur PIN-Anmeldung
</Link>
<span className="text-white/50">
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,400 @@
import React, {
useEffect, useMemo, useState, useCallback, startTransition, useDeferredValue
} from "react";
import {
getCurrentUser, getProducts, createBooking, getFavorites,
updateFavorites, logout, API_BASE, getCategories
} from "../api";
import ProductCard from "./ProductCard";
import CartItem from "./CartItem";
import { useLocation, useNavigate } from "react-router-dom";
const DEFAULT_BG1 = "#0a6bff";
const DEFAULT_BG2 = "#00e0aa";
function applyOrderColorsFromStorage() {
const c1 = localStorage.getItem("order_bg1") || DEFAULT_BG1;
const c2 = localStorage.getItem("order_bg2") || DEFAULT_BG2;
document.documentElement.style.setProperty("--order-bg1", c1);
document.documentElement.style.setProperty("--order-bg2", c2);
}
const PAGE_SIZE = 20;
// deterministische Farbe je Kategorie
function hueFromString(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return (h >>> 0) % 360;
}
// Pastell (inaktiv) vs. kräftig + Glow (aktiv)
function chipStyleFor(cat, active) {
const strong = (h) => ({
bg: `hsl(${h} 85% 72%)`,
br: `hsla(${h} 65% 35% / .60)`,
glow: `0 0 0 5px hsla(${h} 95% 55% / .40), 0 0 22px hsla(${h} 95% 55% / .50)`,
fg: "#0b1220",
});
const soft = (h) => ({
bg: `hsl(${h} 65% 90%)`,
br: `hsla(${h} 40% 40% / .35)`,
glow: "none",
fg: "#0b1220",
});
if (cat === "Alle") return active
? { bg:"hsl(210 75% 74%)", br:"hsla(210 65% 35%/.6)", glow:"0 0 0 5px hsla(210 95% 55%/.4),0 0 22px hsla(210 95% 55%/.5)", fg:"#0b1220" }
: { bg:"hsl(210 55% 90%)", br:"hsla(210 40% 40%/.35)", glow:"none", fg:"#0b1220" };
if (cat === "Favoriten") return active
? { bg:"hsl(52 90% 72%)", br:"hsla(52 70% 35%/.6)", glow:"0 0 0 5px hsla(52 98% 50%/.4),0 0 22px hsla(52 98% 50%/.5)", fg:"#0b1220" }
: { bg:"hsl(52 80% 90%)", br:"hsla(52 55% 40%/.35)", glow:"none", fg:"#0b1220" };
const h = hueFromString(cat);
return active ? strong(h) : soft(h);
}
// robustes Aktiv-Flag (API kann bool/zahl/string liefern)
function isActiveProduct(p) {
const v = p?.is_active;
return v === true || v === 1 || v === "true" || v === "1";
}
const MemoProductCard = React.memo(ProductCard);
export default function Order({ user: userProp }) {
const navigate = useNavigate();
const location = useLocation();
const stateUser = location.state?.user || null;
const [user, setUser] = useState(userProp || stateUser || null);
const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]);
const [selectedCategory, setSelectedCategory] = useState("Alle");
const deferredCategory = useDeferredValue(selectedCategory); // INP
const [page, setPage] = useState(1);
const [cart, setCart] = useState({});
const [favorites, setFavorites] = useState(new Set());
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const collator = useMemo(() => new Intl.Collator("de", { sensitivity: "base" }), []);
useEffect(() => {
let cancelled = false;
// Farben sofort anwenden + Listener registrieren
applyOrderColorsFromStorage();
const onChange = (e) => {
const { bg1, bg2 } = e.detail || {};
if (bg1) document.documentElement.style.setProperty("--order-bg1", bg1);
if (bg2) document.documentElement.style.setProperty("--order-bg2", bg2);
};
window.addEventListener("order-bg-change", onChange);
// Daten laden
(async () => {
setLoading(true);
try {
const [u, prod, cats] = await Promise.all([
userProp || stateUser ? (userProp || stateUser) : getCurrentUser(),
getProducts(),
typeof getCategories === "function" ? getCategories() : Promise.resolve(null),
]);
if (cancelled) return;
setUser(u || null);
setProducts(Array.isArray(prod) ? prod : []);
setCategories(
cats && Array.isArray(cats)
? cats
: Array.from(new Set((prod || []).map((p) => p.category).filter(Boolean)))
);
if (u?.id) {
try {
const f = await getFavorites(u.id);
if (!cancelled && Array.isArray(f)) setFavorites(new Set(f));
} catch {}
}
} catch (e) {
if (!cancelled) setError(e?.message || "Initiale Daten konnten nicht geladen werden.");
} finally {
if (!cancelled) setLoading(false);
}
})();
// Cleanup des Effects
return () => {
cancelled = true;
window.removeEventListener("order-bg-change", onChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userProp, stateUser]);
const activeProducts = useMemo(
() => (products || []).filter(isActiveProduct),
[products]
);
const sortedProducts = useMemo(() => {
const arr = (activeProducts || []).slice();
arr.sort((a, b) => collator.compare(a?.name || "", b?.name || ""));
return arr;
}, [activeProducts, collator]);
const filtered = useMemo(() => {
if (deferredCategory === "Favoriten") {
return sortedProducts.filter((p) => favorites.has(p.id));
}
if (deferredCategory === "Alle") return sortedProducts;
return sortedProducts.filter((p) => p.category === deferredCategory);
}, [sortedProducts, deferredCategory, favorites]);
const onSelectCategory = useCallback((cat) => {
startTransition(() => {
setSelectedCategory(cat);
setPage(1);
});
}, []);
const DEFAULT_AVATAR = "/avatar-default.png";
const avatarSrc = useMemo(() => {
const raw = user?.avatar_url?.trim();
if (!raw) return DEFAULT_AVATAR;
if (/^(https?:|data:|blob:|\/\/)/i.test(raw)) return raw;
if (raw === DEFAULT_AVATAR) return raw;
return `${API_BASE}${raw.startsWith("/") ? raw : `/${raw}`}`;
}, [user?.avatar_url, API_BASE]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const startIdx = (currentPage - 1) * PAGE_SIZE;
const endIdx = Math.min(startIdx + PAGE_SIZE, total);
const pageItems = useMemo(
() => filtered.slice(startIdx, endIdx),
[filtered, startIdx, endIdx]
);
const handleSelect = useCallback((product) => {
startTransition(() => {
setCart((prev) => {
const qty = prev[product.id]?.quantity || 0;
return { ...prev, [product.id]: { product, quantity: qty + 1 } };
});
});
}, []);
const handleIncrement = useCallback((id) =>
startTransition(() => {
setCart((prev) => ({ ...prev, [id]: { ...prev[id], quantity: prev[id].quantity + 1 } }));
}), []);
const handleDecrement = useCallback((id) =>
startTransition(() => {
setCart((prev) => {
const item = prev[id];
if (!item) return prev;
if (item.quantity <= 1) {
const { [id]: _, ...rest } = prev;
return rest;
}
return { ...prev, [id]: { ...item, quantity: item.quantity - 1 } };
});
}), []);
const handleToggleFavorite = useCallback(async (id) => {
const before = new Set(favorites);
const next = new Set(favorites);
next.has(id) ? next.delete(id) : next.add(id);
setFavorites(next);
try {
if (!user?.id) throw new Error("Kein Nutzer geladen.");
await updateFavorites(user.id, [...next]);
} catch (e) {
setFavorites(before);
setError(e?.message || "Favoriten konnten nicht gespeichert werden.");
}
}, [favorites, user?.id]);
const cartItems = useMemo(() => Object.values(cart), [cart]);
const cartTotalCents = useMemo(
() => cartItems.reduce((s, { product, quantity }) => s + product.price_cents * quantity, 0),
[cartItems]
);
const handlePay = useCallback(async () => {
const balance = user?.balance_cents ?? 0;
if (balance < cartTotalCents) { setError("Zu wenig Geld."); return; }
try {
for (const { product, quantity } of cartItems) {
await createBooking(
user.id,
product.id,
quantity,
product.price_cents * quantity
);
}
try {
const refreshed = await getCurrentUser();
if (refreshed) setUser(refreshed);
} catch {}
setCart({});
try { await logout(); } finally { navigate("/", { replace: true }); }
} catch (e) {
setError(e?.message || "Bezahlen fehlgeschlagen.");
}
}, [cartItems, cartTotalCents, navigate, user?.balance_cents, user?.id]);
const handleLogout = useCallback(async () => {
try { await logout(); } finally { navigate("/", { replace: true }); }
}, [navigate]);
const effectiveBalanceCents = (user?.balance_cents ?? 0) - cartTotalCents;
const balance = (effectiveBalanceCents / 100).toFixed(2).replace(".", ",");
const balanceColor = effectiveBalanceCents <= 0 ? "text-red-400" : "text-green-400";
// Kategorien-UI aus aktiven Produkten ableiten (ohne useMemo)
const catsBase = (categories && categories.length)
? categories
: Array.from(new Set((activeProducts || []).map(p => p.category).filter(Boolean)));
const catsForUI = ["Alle", ...catsBase, "Favoriten"];
if (loading) {
return <div className="flex items-center justify-center h-screen bg-gray-900 text-white">Lädt </div>;
}
return (
<div className="flex h-screen">
{/* Produktbereich */}
<div
className="w-3/4 flex flex-col p-4"
style={{
background: `
radial-gradient(1600px 800px at 70% 0%, var(--order-bg1, ${DEFAULT_BG1}) 0%, transparent 60%),
radial-gradient(1600px 800px at 10% 100%, var(--order-bg2, ${DEFAULT_BG2}) 0%, transparent 60%),
linear-gradient(180deg,var(--order-bg2, ${DEFAULT_BG1}) 0%,var(--order-bg1, ${DEFAULT_BG2}) 100%)`
}}
>
{/* Kategorie-Chips */}
<div className="flex flex-wrap items-center gap-3 mb-3">
{catsForUI.map((cat) => {
const active = selectedCategory === cat;
const c = chipStyleFor(cat, active);
return (
<button
key={cat}
onClick={() => onSelectCategory(cat)}
className="px-4 py-2 rounded-xl font-semibold transition shadow-sm hover:shadow outline-none border focus:ring-2"
style={{
backgroundColor: c.bg,
color: c.fg,
borderColor: c.br,
boxShadow: active ? c.glow : "none",
transform: active ? "translateZ(0) scale(1.02)" : "none",
transition: "background-color .14s, border-color .14s, box-shadow .16s, transform .12s",
}}
>
{cat}
</button>
);
})}
<div className="ml-auto text-sm text-gray-400">
{total === 0 ? "0" : `${startIdx + 1}`}{endIdx} von {total}
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4 overflow-auto flex-1 auto-rows-min">
{pageItems.map((p) => (
<div
key={p.id}
style={{ contentVisibility: "auto", containIntrinsicSize: "300px 360px" }}
>
<MemoProductCard
product={p}
onSelect={handleSelect}
isFavorite={favorites.has(p.id)}
onToggleFavorite={handleToggleFavorite}
count={cart[p.id]?.quantity || 0}
/>
</div>
))}
{pageItems.length === 0 && (
<div className="text-gray-400 italic">Keine Produkte gefunden.</div>
)}
</div>
{/* Pagination */}
<div className="mt-3 flex items-center justify-center gap-1 select-none">
<button
onClick={() => startTransition(() => setPage((p) => Math.max(1, p - 1)))}
disabled={currentPage <= 1}
className="px-3 py-1 rounded-md bg-gray-700 text-gray-200 disabled:opacity-40"></button>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
<button
key={n}
onClick={() => startTransition(() => setPage(n))}
className={`px-3 py-1 rounded-md ${n === currentPage ? "bg-green-500 text-white" : "bg-gray-700 text-gray-200 hover:bg-gray-600"}`}>
{n}
</button>
))}
<button
onClick={() => startTransition(() => setPage((p) => Math.min(pageCount, p + 1)))}
disabled={currentPage >= pageCount}
className="px-3 py-1 rounded-md bg-gray-700 text-gray-200 disabled:opacity-40"></button>
</div>
</div>
{/* Warenkorb */}
<div className="w-1/4 relative bg-gray-950 p-4 flex flex-col justify-between">
<div className="relative z-10 flex flex-col flex-1">
<div className="flex items-center mb-4">
<img
src={avatarSrc}
alt="Profil"
className="w-12 h-12 rounded-full mr-3 object-cover ring-1 ring-white/10"
loading="lazy" decoding="async"
onError={(e) => { e.currentTarget.onerror = null; e.currentTarget.src = DEFAULT_AVATAR; }}
/>
<span className="font-semibold text-white text-lg">
Willkommen {user?.name || user?.alias}
</span>
</div>
<div className="mb-4">
<div className="text-white text-sm font-bold">Kontostand:</div>
<div className={`text-2xl font-bold ${balanceColor}`}>{balance}</div>
</div>
<div className="bg-gray-100 rounded-xl p-4 overflow-auto flex-1 mb-4">
{Object.values(cart).length === 0 && <div className="text-gray-500 italic">Warenkorb ist leer</div>}
{Object.values(cart).map((item) => (
<CartItem key={item.product.id} item={item} onIncrement={handleIncrement} onDecrement={handleDecrement} />
))}
</div>
{error && <div className="text-red-500 mb-2 truncate">{error}</div>}
<div className="text-right text-white font-semibold mb-2">
Einkaufssumme: {(cartTotalCents / 100).toFixed(2).replace(".", ",")}
</div>
</div>
<div className="relative z-10 flex gap-3 mb-3">
<button onClick={handleLogout}
className="flex-1 bg-red-700 hover:bg-red-600 text-white py-2 rounded-xl font-semibold">
Abbrechen
</button>
<button onClick={handlePay}
disabled={cartItems.length === 0}
className="flex-1 bg-green-700 hover:bg-green-600 text-white py-2 rounded-xl font-semibold disabled:opacity-50">
Bezahlen
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { loginWithPin } from '../api';
export default function PinLoginPage() {
const [pin, setPin] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const maskChar = '•';
const masked = pin.length ? maskChar.repeat(pin.length) : '\u00A0';
const push = (d) => {
if (loading) return;
setError('');
setPin((p) => (p.length < 6 ? p + d : p));
};
const backspace = () => !loading && setPin((p) => p.slice(0, -1));
const reset = () => { if (!loading) { setPin(''); setError(''); } };
const submit = async () => {
if (pin.length !== 6) { setError('PIN muss 6 Ziffern haben'); return; }
setLoading(true);
try {
const user = await loginWithPin(pin);
setPin('');
// Sofort navigieren; Order lädt parallel alle Daten (Promise.all)
navigate('/order', { replace: true, state: { user } });
} catch (e) {
setError(e.message || 'Login fehlgeschlagen');
} finally { setLoading(false); }
};
useEffect(() => {
const onKey = (e) => {
if (/^[0-9]$/.test(e.key)) push(e.key);
else if (e.key === 'Backspace') backspace();
else if (e.key === 'Enter') submit();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pin, loading]);
return (
<div
className="relative min-h-screen w-full flex items-start md:items-center justify-center
bg-gradient-to-br from-[#000F2E] via-[#00246C] to-[#002D86] p-6 md:p-10"
>
{/* Management-Link oben rechts */}
<Link
to="/management-login"
title="Management-Login"
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
aria-label="Zum Management-Login"
>
{/* kleines Zahnrad als Inline-SVG */}
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" width="26" height="26">
<path d="M19.14,12.94a7.14,7.14,0,0,0,.05-.94,7.14,7.14,0,0,0-.05-.94l2.11-1.65a.48.48,0,0,0,.12-.61l-2-3.46a.48.48,0,0,0-.58-.21l-2.49,1a6.77,6.77,0,0,0-1.63-.94l-.38-2.65A.49.49,0,0,0,14.67,2H9.33a.49.49,0,0,0-.48.4l-.38,2.65a6.77,6.77,0,0,0-1.63.94l-2.49-1a.48.48,0,0,0-.58.21l-2,3.46a.48.48,0,0,0,.12.61L4,11.06a7.14,7.14,0,0,0-.05.94,7.14,7.14,0,0,0,.05.94l-2.11,1.65a.48.48,0,0,0-.12.61l2,3.46a.48.48,0,0,0,.58.21l2.49-1a6.77,6.77,0,0,0,1.63.94l.38,2.65a.49.49,0,0,0,.48.4h5.34a.49.49,0,0,0,.48-.4l.38-2.65a6.77,6.77,0,0,0,1.63-.94l2.49,1a.48.48,0,0,0,.58-.21l2-3.46a.48.48,0,0,0-.12-.61ZM12,15.5A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
</svg>
</Link>
<div className="w-full max-w-[760px]">
{/* Kopf mit SVGs aus /public */}
<div className="flex items-center justify-center gap-6 mb-6 select-none">
<img src="/logo_grape.svg" alt="Trauben" className="w-12 h-12 md:w-14 md:h-14" draggable={false} />
<h1 className="text-4xl md:text-5xl font-extrabold tracking-[0.25em] text-white">BACCHUS</h1>
<img src="/logo_glass.svg" alt="Glas" className="w-12 h-12 md:w-14 md:h-14" draggable={false} />
</div>
{/* Maskenfeld feste Höhe */}
<div className="mx-auto max-w-[640px] mb-6">
<div
className="w-full h-16 md:h-20 rounded-2xl px-6 md:px-8
bg-[#000F2E]/85 text-white
border border-[#2B2B45] shadow-[inset_0_0_0_1px_rgba(0,0,0,.25)]
ring-1 ring-[#0c2a36]/40
flex items-center justify-center"
>
<p className="text-3xl md:text-4xl font-black tracking-[0.5em] leading-none select-none">
{masked}
</p>
</div>
{error && <div className="mt-3 text-center text-red-400 font-medium">{error}</div>}
</div>
{/* Keypad */}
<div className="mx-auto max-w-[640px] space-y-4">
{[
['1','2','3'],
['4','5','6'],
['7','8','9'],
].map((row, i) => (
<div key={i} className="grid grid-cols-3 gap-4">
{row.map((n) => (
<button
key={n}
onClick={() => push(n)}
disabled={loading}
className="rounded-2xl py-5 md:py-6
text-white text-3xl font-extrabold
bg-[#000F2E] hover:bg-[#2f3951] active:translate-y-[1px]
border border-[#2B2B45] ring-1 ring-[#0c2a36]/40 shadow
transition disabled:opacity-50"
>
{n}
</button>
))}
</div>
))}
{/* Untere Reihe */}
<div className="grid grid-cols-3 gap-4">
<button
onClick={reset}
disabled={loading}
className="rounded-2xl py-5 md:py-6 text-white text-2xl font-extrabold
bg-[#7a1010] hover:bg-[#8f1414] active:translate-y-[1px]
border border-[#2B2B45] ring-1 ring-[#3e0a0a]/50 shadow transition disabled:opacity-50"
>
X
</button>
<button
onClick={() => push('0')}
disabled={loading}
className="rounded-2xl py-5 md:py-6
text-white text-3xl font-extrabold
bg-[#000F2E] hover:bg-[#2f3951] active:translate-y-[1px]
border border-[#2B2B45] ring-1 ring-[#0c2a36]/40 shadow
transition disabled:opacity-50"
>
0
</button>
<button
onClick={submit}
disabled={loading || pin.length !== 6}
className="rounded-2xl py-5 md:py-6
text-white text-2xl md:text-3xl font-extrabold
bg-[#00DD00] hover:bg-[#009030] active:translate-y-[1px]
border border-[#0a3b20] ring-1 ring-[#0a3b20]/50 shadow transition
disabled:opacity-50"
>
OK
</button>
</div>
<div className="pt-3 text-center text-white/60 text-xs select-none">
Hier maybe noch Impressum etc
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React, { useMemo, useState, useEffect } from 'react';
import { FaRegHeart, FaHeart } from 'react-icons/fa';
export default function ProductCard({
product,
onSelect,
isFavorite,
onToggleFavorite,
count = 0, // Menge im Warenkorb
}) {
const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase();
// Fester Fallback (Datei MUSS in /public liegen und so heißen)
const FALLBACK_IMG = '/no-image_img.jpg';
// Letzter Fallback: eingebettetes SVG (funktioniert ohne Datei)
const FALLBACK_SVG =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
<rect width="100%" height="100%" fill="#111827"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
fill="#9CA3AF" font-family="sans-serif" font-size="28">
Kein Bild
</text>
</svg>`
);
// Bild-Kandidaten (Reihenfolge = Priorität)
const candidates = useMemo(
() =>
[
product?.image_url, // absolute oder /public-URL
product?.image, // alternative Feldquelle
`/${baseName}_img.jpg`, // z.B. /spezi_img.jpg (in /public)
`/images/${baseName}.jpg`, // z.B. /images/spezi.jpg (in /public/images)
].filter(Boolean),
[product, baseName]
);
// Aktueller Kandidaten-Index + Quelle
const [idx, setIdx] = useState(0);
const current = candidates[idx] || FALLBACK_IMG;
const [imgSrc, setImgSrc] = useState(current);
// WICHTIG: Bei Kandidatenwechsel wieder von vorne anfangen
useEffect(() => {
setIdx(0);
setImgSrc(candidates[0] || FALLBACK_IMG);
}, [candidates]);
const handleError = () => {
const nextIdx = idx + 1;
if (nextIdx < candidates.length) {
setIdx(nextIdx);
setImgSrc(candidates[nextIdx]);
} else if (imgSrc !== FALLBACK_IMG) {
setImgSrc(FALLBACK_IMG);
} else {
setImgSrc(FALLBACK_SVG);
}
};
return (
<div
role="button"
tabIndex={0}
className="relative rounded-xl overflow-hidden bg-gray-800 cursor-pointer transform transition duration-150 hover:scale-105 ring-2 ring-transparent hover:ring-green-500"
onClick={() => onSelect(product)}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(product)}
>
<img
src={imgSrc}
onError={handleError}
alt={product?.name || 'Produktbild'}
className="w-full h-40 object-cover select-none"
draggable={false}
loading="lazy"
/>
<div className="p-2 bg-gray-950">
<h3 className="text-white font-semibold text-lg truncate">{product?.name}</h3>
<p className="text-gray-400 text-sm truncate">
{product?.volume_ml ? `${(product.volume_ml / 1000).toFixed(1)}L • ` : ''}
{((product?.price_cents ?? 0) / 100).toFixed(2).replace('.', ',')}
</p>
</div>
{/* Favoriten-Button */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleFavorite(product.id); }}
className="absolute top-2 right-2 text-red-600 text-xl"
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
>
{isFavorite ? <FaHeart /> : <FaRegHeart />}
</button>
{/* Mengen-Badge */}
{count > 0 && (
<div className="absolute top-2 left-2 bg-green-600 text-white text-xs font-bold px-2 py-1 rounded-full">
x{count}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View 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>
);
}

View 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
***+++++++++++++++++++++++++====================+*#%@@@@@@@@@@@@@@%*++======================================================================================+++***###%%%%###***++++===
*++++++++++++++++++++++++++=====================++*%@@@@@@@@@@@@@@%*+======================================================================================++++***###%%%%###***++++===
**++++++++++++++++++++++++=======================+*%@@@@@@@@@@@@@@@#++==============================----===================================================+++++***#######****+++++===
**++++++++++++++++++++++++=======================+*%@@@@@@@@@@@@@@@#*+=================================---=================================================+++++*************+++++====
**+++++++++++++++++++++++++++====================+*#@@@@@@@@@@@@@@@%*+=================================-===================================================+++++++++*******+++++++====
+++++++++++++++++++++++++++++====================+*#%@@@@@@@@@@@@@@@#+===================----===----=====----=================================================+++++++++++++++++++=====
+++++++++++++++++++++++++========================++*#%@@@@@@@@@@@@@@%*+================----===-----------------------------======================================+++++++++++++=+======
+++++++++++++++++++++++++=========================+*#%%@@@@@@@@@@@@@%*+++============--------===--------------------------------------================================================
++++++++++++++++++++++++++========================+*#%@@@@@@@@@@@@@@@#*##*##=========---------------------=-----------------------==--================================================
++++++++++++++++++++++++++========================++*%@@@@@@@@@@@@@@@%#+**#+++======-----------------------------------------------===================================================
++++++++++++++++++++++============================++*%@@@@@@@@@@@@@@@@+****##*+=====------------------------------------------------=*@%#=---=========================================
+++++++++++++++++++++++++==========================+*##%%@@@@@@@@@@@@@*#*****%#*=====---------------------------------------------=+++*#*=------------================================
+++++++++++++++++++++++++==========================++**#%%@@@@@@@@@@@@#%##****###+=-=-------------------------------------------=++*%##%#+=====-------================================
+++++++++++++++++++++===============================++*#%@@@@@@@@@@@@##%###*++*%#%++=----------------------------------------==++*%#***###==-------===================================
++++++++++++++++++==+===============================++*#%@@@@@@@@@@@@##%####*+++*%%%*+==-----------------------------------==++#%%######@%==-------===-===============================
++++++++++++++++++++++===============================+*#%@@@@@@@@@@@@%*#***++**+*##%@**======---------------------------==+#*%@%%%##**#%@#==-----------======--=======================
+++++++++++++++++==+++===============================+*#%%@@@@@@@@@@@@*#****+++++*###@%#@##*+==--------=-----------=====#%*%%%%#+*+*#%%%@*---=====-------=============================
+++++++++++++++++====================================++*#%%%%@@@@@@@@@#***++=====+*##%@##@@@@##**++====+===-=====++%%%%%+@@%%%#*##**%%@@%==--------------=============================
++++++++++++++========================================+*##%%%@@@@@@@@@@***++=====++*%@@@%#@@@###@%*%%%%%@##%**%*#@%@@@*%@@@#***+***#*%%@*------------------===========================
+++++==+++++++========================================++*#%%@@@@@@@@@@@***#*++++++*###@@@*@@@@#%@%+#%%@@@***%@##@@@@@+@@@%#**++*++*##%%#=--------------------=========================
++++===================================================+*#%%%%@@@@@@@@@@%****+++**##%%@@@%@@%%#+%%@@@@@@@@@+%%%@@@@@*%@@@@%*+++++++#%%%+-----------------------=======================
+++++++++==============================================+**#%%%%@@@@@@@@@#+*##***#*****#%#%##*@%@@#%@@%@%@@%@@@*%%@@%%@@@@@%#*******###+----------------==------=======================
+++++++++==============================================++*#%%%%@@@@@@@@%+*#%%@%*++++++#%%%%@@@@@%@###@@@#@#@@@@@%#%%#%%%%@@%@%#%@@%#@@#----------------------=========================
++++++++================================================+*#%%%@@@@@@@@@#+#%%%##+=++*###%@%%%@@@@@*#@@@@@@@#%@@@@@@@#@###**#%%%@@@@@@#@*----------------------=========================
+++++++++===============================================++*#%@@@@@@@@@@%+#@@#+==+**#%%##%%%%#%@@@*#@@@@@@%**@@@%%#@@@%%##****#@@@@@@##=------------------------=======================
+++++++++=======================================------===+*#%@@@@@@@@@@@%%@%**+**#%#%##%##%#%@@@@#+#%@@@@*#@@@%@#%#%%%%@@%##***%@@@@#+=------------------------=======================
+++++==============================================--====+*#%@@@@@@@%%##%%*#*#%#######%%@##%%%#@@%*%@@@@@+*@@%%@*#%%*#%%%@@%#*##@@@@#==------------------------=======================
+++=++==============================---==-------=========+*#%@@@@@@%%#*#%#*******#%%%@%@%%+%@#*@@@##@@@@@*@@#+%@##%%@%%*%@@@%%%%%@@@%+=------------------------=======================
++++============================--------------------=====+*#@@@@@%###****#+=+*###%%%#%@#++*%@%**@@#@@@@@#@@@**@@%+*##@@@%*#@%@%%%%%@@+---------------------------=====================
+++=============================----------------------===+*%@@@@%####++++==+*##**+++*+*#*=+@@#++@@@@@@@@%@@#+#@@#+*%%@###***%##*%@@%%#=--------------------------=====================
++===========================-------------------------==+*#@@@%%%##%*++*++=+#*##*++*+***+++@@#++%%%@@@@%%%%*+%@@+++*#**+**##%#**#%%#%#+=----------------------------==================
++========================----------------------------=+*#@@@%%%###%+*+++*%@@@@%%@@@@@@@@@**%%#####%%@%#**##%@@%*+**#@%#***#@%%#*%%%#*=-----------------------------==================
++========================---------------------------==+#%@@@%%###****++#%@%#**++*@%***@@@@@#+*##*##%%%#%%@@@@%%@@@@@@@@@@@%@%@%%#@%##+-------------------------------================
++=====================-----------------------------==+#%@@@@%%%%###*++#@@#+++=+==@@***%@%%#@#==**###%%%%@%##@@@@@@@@@@@@#*%@@@@@@%%%#*=----------------------------------============
======================-----------------------------==+#%@@@@%%#%%%###+%@@+=+++**+==@@#**#**@@@=-++***###%%++@@@@@@@@%%%@+=+=++*%@@@%%#*=----------------------------------============
+====================-----------------------------==+#%@@@@@%########%%*++++*###*++==*@@@@@@@@--==****#*++=+@@@@@%%%%@%==++**+#@@@@@%%*=------------------------------------==========
=====================-----------------------------=+*%@@@@%%########%#+===++++*#***+**+=---=*%+=++++*****+++@%****#+===+*####@#*%@@@@#==------------------------------================
=====================----------------------------==*#@@@@%%########%*+=+====++++++*+=+***+=+@@#**+++++*###%@@*=--=++#+*#%%#%###%%%@@@*=------------------------------=================
====================-----------------------------=+#%@@@%%####*******++====++++**++***++==*#%####*****##@@@%@@+=*#***+*##**%@%***##%*==------------------------------=================
=====================-----------------------:---=+*#%@@%%######**##***++++%@@@%#****+=====+***#%####*###@@@%@#*+=+*##%%#*%@%%#*******+=----------------------------=---===============
==================-----------------------------=+*#%@@@@%####***##****+*#%#***++++++---=+*++++#@%%%#*#%@@@%%%#*#*+=+*#**##%@%%%#*##**+==------------------------------================
=====================--------------------------=+#%%@@@@%####********++++*#***##*+==-==**%+*==-+@@%%%%@@@#++###%%%%***#*###%%#%%%####+==------------------------------================
=================-=---------------------------=+*#%@@@@%####***#######***+***+*#*++=-=*+++===-:-=#@@@@@+=-=++*+++++++#%@@@@@%%%@%###*++=---------------------------------=============
================-----------------------------==+*%@@@@@%%#####*##%##******++**==**++-==++===----=+@@@@+=--=+*#+++++*++#%@@@%%@@%%%%%#*+=--------------------------------==============
===============------------------------------=+*#%%@@@@#######*#%%#*****+==+++*#*#*+==-=+++++++++*#@@#+++**#####*##*##%@%*#@%%#%@@%#**+=-------------------------------===============
==============------------------------------==+*#%%%%%%%#####**#%%#*++**#*++++++=+=======++*#%%%%@@@@%@@@@@@%%%##**=*#%@@@%%#*#@@@%#*++==-------------------------------==============
===============----------------------------===+*#%%%%%%%#####**#*****#####**+++===++=++++++++++++++****#########*+#%%*#%#%#%@@@@%##**++==----------------------------------===========
===============----------------------------==+**#%%@@@@%#####*+*#%#**#####*+++*+*+++++**++**+++++++*###*#######%##+%*%#+*#%@@@@@@@#*+++==------------------------------------=========
================---------------------------==+*##%%@@@@%##**#####%#**####**+++*%%*#+**#*###%%%#%#*#####%%%%%%%%@%##+#+#%##@@@@@@@%##**+=------------------------------------==========
=====================---------------------===+*#%%%%@@###########%#+**###***++*#%@*%@%%##%%@%@@@@@@@@@@@@@%@%%%%%#%#*@%@@@@@@@@@%###***=---------------------------------------=======
==============================-----------====+*##%%%%@@@%##**#####*++**#*#+***+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@%@@@@@@@#@@#%####**==-------------------------------------========
=====================================-=======+*##%%%%@@@%##****###*++*+##+***+**#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%#####**=--------------------------------------========
============================================+**##%%@@@@@%##****###++++***#%%*****#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@%%%*%%####*+=-------------------------------------=======+
+++++++++++++++++++=========================+*##%%%@@@@%%#######***++***###*###*##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%*%%####*===------------------=--------------========++
+++++++++++++++++++++++++++================++*##%%%@@@%%%#####*****++*+**######*#####%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@%@@%%%*%%#*#*+===========================================++
++++++++++++++++++++++++++++++++==========+++*##%%%%%%%%%###*******+++*****##%#*#####*****###@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%#%%*#%%**++==========================================++
+++++++++++++++++++++++++++++++++++======+++**#%%%%%%%%##********#*++++*****###***#************###%%%@@@@@@@@%@@@@@@@%@%@@@%%%%%##%*###*++=======================================+++++
+++++++++++++++++++++++++++++++++++++==+++++*##%%%%%%%##*++++++****++****+**********+++**********#***##%%%#%%%%%%%%@@#%%%%@@@%%%%%###@%#*+++=++++++++++++++++++=++++++++++++++++++++++
+++++++++++++++++++++++++++++++++++++===+++**#%%%%%%%%#*+++++++*****+++***#***#******+****************#%%#*##%#%##%%@@%%@%%@@%%%%%%@@##**++=++++++++++++++++++++++++++++++++++++++++++
+*******++++++++++++++++++++++++++++++++++**##%%%%%%%#*++==+++++********+***##*+***************#******#%%#####%###%@@@@@%%%%%%@@%%%%%####+++++++++++++++++++++++++++++++++++++++++++++
**********++++++++++++++++++++++++++++++++**##%%%%%%##*++==+++++++***###*+++*##**++++*********#####***%@%####%%%%%%%@@@@%%@@@@@@@%%#%%%#*++++++++++++++++++++==+++++++++++++++++++++++
************++++++++++++++++++++++++++++++**#%%%%%%%##*++++++++++++****#*+++++*********###########%###%%%%%%%%%%%%@@%%@@@@@@@%%%%%%%%#*+++++++++++++++++++++====++++++++++++++++++++++
**************++++++++++++++++++++++++++++**#%%@@%%%##*+++++++++++***+++******++++*#####%%%%%%#%#%%%#%%%%%%%%%%%%%%%%%%%%%%%%#%%%%%##***+++++++++++++++++++++++=++++++++++++++++++++++
**************++++++++++++++++++++*+++++++**#%%@@%%%%#**+++++*********++**###****+*+++**####%%%%%%%%%%%%@@@@@%@@@@%%%%%%%%%%%%%@%%###*******++++++++++++++++++++++++++++++++++++++++++
######**********++++++++++++++++****+++++***##%@@@%%%##***+*************++++**#######***++**#%%%%%%@@%%@@@@@@@@@@@%%%%%%%@@@@@%%%%@%%%@@%##**+++++++++++++++++++++++++++++++++++++++**
#########*****+++++++++++++++++***+*+++++***##%%%@@@%%##******######***##**+++++**#####****##%%%%%%@@@@@@@@@@@@@@@%%%@@@@@@@%%%@@@@@@@#**+*++++++++++++++++++++++++++++++++++*********
#########*****++++++++++++++++++*******++***####%%%%%%%#####%%%%%%%##*+*#%%%*+++++++****#%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@###%@@@@%#*++++++++++++++++++++**********+*+****************
########******++++++++++++++++++************#####%%%%###%%%%@@@@@@%%#*+++*%%##****++*****###%%%@@@@@@@@@@@@@@@@@@@@@@@@%#**#%@@%#****+++===++++***++++**********************#**#####**
##########*****+++++++++++++++++************###%%%%%%#%%%%%@@@@@@%%#**++++++*#%@@%###%#####%@@@@@@@@@@@@@@@@@@@@@@@@@%##*#%@@%*+++******+++=++*************#######################%%**
########********+++++++++++++++*************###%%%%%%%%%%%@@@@@@%##**+++++==+++*#%@@@%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@#*++++*******+++++++**********#####################%%%%%%**
%######*************************************####%%%@@@@@@@@%%%%%#***+++***+====++*###%%%@@@@@@@@@@@@@@@@@@@%%#%%@@@@@@@@@#**+++++++*****+++++************##############%%%%%%%%%%%%%**
%%%%####********#######*********************####%%%%@@@@%%%#####*****+*#%#*+===++******###%@@@@@@@@%%%%%%%##***##%%@@@@@##*+===+++******++*************#########%%%%%%%%%%%%%%%%%%%%**
%%%%%%######################****************###%%%%%%%%%##********++++++*+*++++++++****####%%@@@@@@%%%%%%###*****###%%@%##*+++************************######%%%%%%%%%%%%%%%%%%%%%%%%#*
%%%%%%%####################************#########%%%%%%%#*******+++++++++==++++**+++*#####%%%%@@@@@@@@@@@@%%####*****#####**+**+=+++*****+****+**###########%%%%%%%@@@%%%%%%%%%%%%%%%#*
%%%%%%%########%%%%##%#####********####%%%%%#####%%%%%%##*##***********++=+++++++++******###%%@@@@@@@@@@@@%%%##*******##*****++++++++*#**+++*++*##########%%%%%@@@@@@@@@@@@@@@@%%@%%**
%%%%%%%##%%####%%%########*****#######%%%%%%#####%%%%%%###%%%%##########**##%#*+++++++****##%@@@@%%%%%%%%%%%%##*******###**+++=+==++++*#*++++++#%%%%%%%%%%%%%%%%@@@@@@@@@@@@@@@%@@%%#*
%%###%##%%%########****###**+*******#############%%%%%%%%%%@@@@@@@@@@%%%##%@@@@%****####%%%%@@@@@%%########%%%##****##%###*++=====+++++***+++**%%%@%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@#*
#####%%%%######*****++*******************########%%%%%#%%%@@@@@@@@@@@@@%#*++*%@@@@@@@@@@@@@@@@@@@%########%%%%%%%%%%%%%%#+=======+++****+++**##%@@@@@@@%%%%@@@@@@@@@@@@@@@@@@@@@@@@@#*
%###%%%%########%%%#**********************#######%%%%##%%%@@@@@@@@@@@@@@%*++++*#%@@@@@@@@@@@@@@@@%########%%@@@@@@@@@@@#========++++********##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
%%%%@@@%%###%@@@@@@@@@%%%%#####****####################%%@@@@@@@@@@@@@@@%#*+++++++**##%%%%%%@@@@@@########%%@@@@@@@@@@#+======+++++*++****###%@@@@@%%%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@#*
@@@@@@@%%###%%@@@@@@@@@@@@@@@@%%%%#%%%%%##%@@%%%%#####%%%@@@@@@@@@@@@@@@@@%##****++++++++++**#%@@%#*######%@@@@@@@@@@#+++=====+++++++++######@@@@@%%%%%%%%%%%%%%%%%%%%%@@@@@@@@@@@@@#*
%@@@@%%##*+++++******##%%@@@@@@@@@@@@@@%##%@@@@@%%%%%%%%@@@@@@@@@@@@@@@@@@%###%%#*+++++++++++************#%@@@@@@@@@%+=+++=====++++***#@%##%@@@@@@%%%%%%@@%%%%%%%%%%%%%%%@@@@@@@@@@@#*
%%%#**++======++++++++****#%@@@@@@@@@@@%%%@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#*+++++++++++++++++++++++******#%@@@@@@@@%+=++++++++=++*###%#####%@@@@@@@@@@@@@@@%%%%@@@@%%%%%@@@@@@@@@@@@#*
#**++==========++++++*****+**%@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##***++==+++++++++++++++++++++*++++*#%@@@@@@@*++++++++++*+****###%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
+++++===========++++**++*****#%%@@@%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%#******+==+*##*+++++++++**+++++***+++*#%@@@@@#+===+++++*+++*#%@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
*+++++========++*++***+++******#####*****#%@@@@@@@@@@@@@@@@@@@@@@@%%#####%%#*+=++++==+++*###***++++**##*+++*#@@@%*+===+++********##%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*
+++++*++======++******++++*########*******#%%@@@@@@@@@@@@@@@@@@@@@%%%%@@@@@%#+++***++**###******+++*#%%#*++*%@@%+=====++*******#%%%%%%%@@@@@@@@@@@@@@@@@%%#####%%@@@@@@@@@@@@@@@@@@@#*
#*++++**+===++++*####**+++*#%%%%%%%%%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#**#%@@%@@%%#*++++++++**##%#***#%%#+++=+++++*******##%%#%%%@@@@@@@@@%%%%####*******#%@@@@@@@@@%%%@@@@@@@#*
@%##***++*++++*++*#*####**#%%@@%%%@@@@@@@@@%%%@%%@@@@@@@@@@@@@@@@@@@@@@@@@%%%#*+*#%@@@%*++++++++++#%%%##*#%%#**++=+++**#****#%%%%%%%%%@@@@@@@@@%%#####******###%@@@@@@@@@@%%%@@@@@@@#*
%@@@@@@%#%#******###*#%%##%%%@@%@@@@@@@%@@@%#%@@@@@@@@@@@@%%%%@@@@%%%%%%%%%%%%#++++####*++****+*++*#%%%%##%%#+++==++*###%%#%%%@@@@@@#*#%@@@@@%%##%%@@@%%#######%%@@@@@@@@@%%%%@@@@@@#*
@@@@@@@@@@@@@@@%@@@%#%%%@@@%%@@@@@@@@@@%@@@%%@@@@@@@%%@@@@@@@@@%%%%%%%%%%%@@@@%*+=+++++***###*++***##%%%%%%%%#++++*#%##%%%%%%###@@@%#*+*#%%##***#%@@@@@@@@@%%%%%%%#%%%@@@@%%%%%%@@@@#*
@@@@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@%@%@@@@@@@@@@@%@@%%%%%%%%%#+++==++*###***+++##%%@%@%%@@@@@%%%%%%%@@@%#%%%%%@@%#*********++**%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@%*
@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@%%%%#+==+++++*****++***%%@#@@@@@@@@@@@@@@@@@@%#%%@@@@@@%#***+******##@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@%#
@@@@@%%@@@@@@@@%%@@@@@@@@@%%@@@@@@@@@@@@@%@@@@@@@@@@%@@@@@@%%@@@@@@@@@@@@@@@@%%#+==+++++++*******#%%%@@@@@@@@@@@@@@@@@@@%%%@@@@@@%#**#***####%%@@@@@@@@@@@@@@@@@@@@@@@@%%%@@@@@@@@@@%#
*/

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">Δ&nbsp;Balance</th>
</tr>
</thead>
<tbody>
{visible.map((r) => (
<tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10">
<td className="py-2 pr-4 font-mono">{fmtDT(r.timestamp)}</td>
<td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td>
<td className="py-2 pr-4 font-mono">{String(r.action ?? "—")}</td>
<td className="py-2 pr-4">{r.info ?? "—"}</td>
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "—"}</td>
</tr>
))}
{loading && (
<tr className="border-t border-white/10">
<td className="py-3 pr-4 text-white/60" colSpan={5}>Laden</td>
</tr>
)}
{!loading && visible.length === 0 && (
<tr className="border-t border-white/10">
<td className="py-3 pr-4 text-white/60" colSpan={5}>Keine Einträge.</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="mt-4 flex items-center gap-3">
<button
className="px-4 py-2 rounded-xl bg-white/10 border border-white/10 hover:bg-white/15 text-white/80 disabled:opacity-50"
onClick={() => load(false)}
disabled={loading || !hasMore}
>
Mehr laden
</button>
<div className="text-white/50 text-xs">
{rows.length} geladen{hasMore ? " (Mehr verfügbar)" : ""}
</div>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&note=${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>
);
}

View 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"
>
&lt; 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>
);
}

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body {
@apply bg-black text-white font-sans;
}
}