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