new init
This commit is contained in:
@@ -1 +0,0 @@
|
||||
VITE_API_URL=http://localhost:8080
|
2168
apps/frontend/package-lock.json
generated
2168
apps/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bacchus-ui",
|
||||
"version": "0.1.0",
|
||||
"name": "bacchus-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -8,17 +8,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"recharts": "^3.1.2"
|
||||
"react-router-dom": "^6.23.0",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,8 @@
|
||||
// api.js
|
||||
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(
|
||||
"(?:^|; )" +
|
||||
@@ -14,7 +13,6 @@ function getCookie(name) {
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
// Holt CSRF
|
||||
async function getCsrfMaybe() {
|
||||
const fromCookie = getCookie("bacchus_csrf");
|
||||
if (fromCookie) return fromCookie;
|
||||
@@ -29,7 +27,6 @@ async function getCsrfMaybe() {
|
||||
return token;
|
||||
}
|
||||
|
||||
//
|
||||
export async function getCsrfToken() {
|
||||
return getCsrfMaybe();
|
||||
}
|
||||
@@ -42,11 +39,9 @@ export async function request(path, options = {}) {
|
||||
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.body !== undefined && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
|
||||
headers["X-CSRF-Token"] = await getCsrfMaybe();
|
||||
|
||||
const res = await fetch(url, {
|
||||
@@ -61,11 +56,7 @@ export async function request(path, options = {}) {
|
||||
const text = await res.text().catch(() => "");
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
/* Hi */
|
||||
}
|
||||
try { data = JSON.parse(text); } catch { /* non-JSON */ }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -79,17 +70,17 @@ export async function request(path, options = {}) {
|
||||
}
|
||||
|
||||
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("&")
|
||||
: "";
|
||||
let qs = "";
|
||||
if (params && typeof params === "object") {
|
||||
const pairs = Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||
if (pairs.length) qs = "?" + pairs.join("&"); // <— nur wenn wirklich Paare existieren
|
||||
}
|
||||
return request(`${path}${qs}`, { method: "GET" });
|
||||
}
|
||||
|
||||
|
||||
/* ===================== Auth / Session ===================== */
|
||||
|
||||
export function loginPin(pin) {
|
||||
@@ -125,11 +116,10 @@ export function getStatsSummary() {
|
||||
return request("/stats/summary");
|
||||
}
|
||||
|
||||
/* ===================== Users & Favorites ===================== */
|
||||
/* ===================== Users ===================== */
|
||||
|
||||
// Parametrische Liste (Suche/Filter/Sort/Paging)
|
||||
export function listUsers(params = {}) {
|
||||
return getJson("/users/", params); // trailing slash beibehalten
|
||||
return getJson("/users/", params);
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
@@ -158,7 +148,6 @@ 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",
|
||||
@@ -200,6 +189,26 @@ export function replaceFavorites(userId, favorites) {
|
||||
});
|
||||
}
|
||||
|
||||
// für logs/admintransactionen
|
||||
function pickName(u) {
|
||||
const full = [u.first_name, u.last_name].filter(Boolean).join(" ").trim();
|
||||
return u.display_name || u.name || (full || null) || u.username || u.email || `User ${u.id}`;
|
||||
}
|
||||
|
||||
// NEU: kein Versuch mehr, /users/lite aufzurufen → keine 422 mehr in der Konsole
|
||||
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
|
||||
const data = await getJson("/users/", { limit, offset }); // nur dieser Endpoint
|
||||
const arr = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
||||
return arr.map(u => ({ id: u.id, name: pickName(u) }));
|
||||
}
|
||||
|
||||
|
||||
export async function getUsersMap(opts) {
|
||||
const list = await getUsersLite(opts);
|
||||
const map = {};
|
||||
for (const u of list) map[u.id] = u.name;
|
||||
return map;
|
||||
}
|
||||
/* ===================== Profile / Avatar ===================== */
|
||||
|
||||
export async function uploadAvatar(file) {
|
||||
@@ -210,7 +219,7 @@ export async function uploadAvatar(file) {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": await getCsrfMaybe() }, // multipart: kein Content-Type setzen
|
||||
headers: { "X-CSRF-Token": await getCsrfToken() },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload fehlgeschlagen (${res.status})`);
|
||||
return res.json();
|
||||
@@ -218,7 +227,7 @@ export async function uploadAvatar(file) {
|
||||
|
||||
export function updateOwnProfile(patch) {
|
||||
return request("/profile/me", {
|
||||
method: "PUT",
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -273,11 +282,10 @@ export function getMyBookings({ limit = 100, offset = 0 } = {}) {
|
||||
}
|
||||
|
||||
export function listBookings({ user_id, limit = 10, offset = 0 } = {}) {
|
||||
return getJson("/bookings/", { user_id, limit, offset });
|
||||
return getJson("/bookings/", { user_id, limit, offset });
|
||||
}
|
||||
|
||||
|
||||
/* ===================== Deliveries (Manager/Admin) ===================== */
|
||||
/* ===================== Deliveries ===================== */
|
||||
|
||||
export function getDeliveries() {
|
||||
return request("/deliveries/");
|
||||
@@ -292,6 +300,56 @@ export function deleteDelivery(id) {
|
||||
return request(`/deliveries/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- utils (numeric) ---
|
||||
function toInt(x) {
|
||||
return x == null || x === "" ? 0 : parseInt(x, 10) || 0;
|
||||
}
|
||||
|
||||
export async function createDeliveryBulk(payload, products = []) {
|
||||
// payload: { supplier, date, invoice_no, note, deposit_return_cents, items:[{product_id, quantity_units, unit_cost_cents}] }
|
||||
|
||||
const withUnits = {
|
||||
...payload, // ← richtig, nicht ".payload"
|
||||
items: (payload.items || []).map(it => {
|
||||
const ps = (products.find(p => p.id === it.product_id)?.pack_size) ?? 1;
|
||||
return {
|
||||
...it, // ← richtig, nicht ".it"
|
||||
quantity_units: toInt(it.quantity_units),
|
||||
unit_cost_cents: toInt(it.unit_cost_cents),
|
||||
units: toInt(it.quantity_units) * ps,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// 1) Versuche echten Bulk-Endpoint
|
||||
try {
|
||||
return await request("/deliveries/bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(withUnits),
|
||||
});
|
||||
} catch (e) {
|
||||
if ([404, 405, 501].includes(e?.status)) {
|
||||
throw new Error("Server unterstützt /deliveries/bulk nicht – bitte Backend aktualisieren.");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// PDF-Import → Draft
|
||||
export async function importDeliveryInvoice(file) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`${API_BASE}/deliveries/invoice/import`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": await getCsrfToken() }, // multipart: KEIN Content-Type setzen
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ===================== Topups (Manager/Admin) ===================== */
|
||||
|
||||
export function patchTopupStatus(topupId, status) {
|
||||
@@ -305,6 +363,27 @@ export function getMyTopups({ limit = 100, offset = 0 } = {}) {
|
||||
return getJson("/topups/me", { limit, offset });
|
||||
}
|
||||
|
||||
export async function listTopupsAdmin({ limit = 200, offset = 0, status_filter, user_id } = {}) {
|
||||
const params = { limit, offset };
|
||||
if (status_filter) params.status_filter = status_filter;
|
||||
if (user_id) params.user_id = user_id;
|
||||
return getJson("/topups", params);
|
||||
}
|
||||
|
||||
export async function createTopupAdmin({ user_id, amount_cents, note = "" }) {
|
||||
return request("/topups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id, amount_cents, note }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTopupStatus(id, status) {
|
||||
return request(`/topups/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ===================== Stats ===================== */
|
||||
|
||||
export function getConsumptionPerUser() {
|
||||
@@ -319,12 +398,22 @@ export function getMonthlyRanking({ year, month, limit = 10 } = {}) {
|
||||
return getJson("/stats/monthly-ranking", { year, month, limit });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/* ===================== 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 });
|
||||
}
|
||||
return getJson("/audit-logs/", { limit, offset, user_id, action, q, date_from, date_to });
|
||||
}
|
||||
|
||||
// Eigene Transaktionen
|
||||
export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
|
||||
@@ -332,26 +421,22 @@ export function getMyTransactions({ limit = 100, offset = 0 } = {}) {
|
||||
}
|
||||
|
||||
// Admin: alle Transaktionen
|
||||
export function getTransactionsAdmin({ limit = 100, offset = 0 } = {}) {
|
||||
return getJson("/transactions", { limit, offset });
|
||||
export function getTransactionsAdmin({ limit = 100, offset = 0, user_id, type, date_from, date_to } = {}) {
|
||||
return getJson("/transactions", { limit, offset, user_id, type, date_from, date_to });
|
||||
}
|
||||
|
||||
/* ===================== Categories (Manager/Admin) ===================== */
|
||||
|
||||
// Liste aller Kategorien (Array<string>)
|
||||
export function getCategories() {
|
||||
return request("/categories/"); // trailing slash wie bei /products/
|
||||
return request("/categories/");
|
||||
}
|
||||
|
||||
// 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 });
|
||||
@@ -359,7 +444,7 @@ export function deleteCategory(name, reassignTo = null) {
|
||||
return request(`/categories/?${params.toString()}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/* ================== Transaktionen Tracker ==============*/
|
||||
/* ===================== Ledger / Topups (Self) ===================== */
|
||||
|
||||
export function getLedgerMe({ limit = 100, offset = 0, types = "topup,booking" } = {}) {
|
||||
return getJson("/ledger/me", { limit, offset, types });
|
||||
@@ -374,84 +459,21 @@ export function createTopup(amount_cents, note = null) {
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== 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`, {
|
||||
export function addLedgerEntry(amount_cents, note = "") {
|
||||
return request("/ledger", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id, amount_cents, note }),
|
||||
body: JSON.stringify({ amount_cents, note }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTopupStatus(id, status) {
|
||||
return req(`/topups/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
/* ===================== Admin Settings (PayPal) ===================== */
|
||||
export function getPaypalSettings() {
|
||||
return request("/admin/settings/paypal");
|
||||
}
|
||||
|
||||
export function updatePaypalSettings({ paypal_me = "", paypal_receiver = "" } = {}) {
|
||||
return request("/admin/settings/paypal", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ paypal_me, paypal_receiver }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function getUsersLite({ limit = 200, offset = 0 } = {}) {
|
||||
const lim = Math.min(Number(limit) || 200, 200);
|
||||
return req(`/users?limit=${lim}&offset=${offset}`);
|
||||
}
|
||||
|
@@ -19,6 +19,18 @@ function applyOrderColorsFromStorage() {
|
||||
document.documentElement.style.setProperty("--order-bg2", c2);
|
||||
}
|
||||
|
||||
// --- NEU: lokale Bestände anhand des Warenkorbs reduzieren
|
||||
function decrementStockLocal(prevProducts, cart) {
|
||||
const delta = {};
|
||||
for (const { product, quantity } of Object.values(cart)) {
|
||||
delta[product.id] = (delta[product.id] || 0) + quantity;
|
||||
}
|
||||
return prevProducts.map((p) =>
|
||||
delta[p.id] ? { ...p, stock: (Number(p.stock) || 0) - delta[p.id] } : p
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -53,6 +65,11 @@ function chipStyleFor(cat, active) {
|
||||
return active ? strong(h) : soft(h);
|
||||
}
|
||||
|
||||
function ringColorForCategory(cat) {
|
||||
if (!cat) return "#22c55e";
|
||||
// nutzt deine bestehende Farblogik der Chips:
|
||||
return chipStyleFor(cat, true).br; // starke Border-Farbe der Kategorie
|
||||
}
|
||||
// robustes Aktiv-Flag (API kann bool/zahl/string liefern)
|
||||
function isActiveProduct(p) {
|
||||
const v = p?.is_active;
|
||||
@@ -236,11 +253,15 @@ const avatarSrc = useMemo(() => {
|
||||
product.price_cents * quantity
|
||||
);
|
||||
}
|
||||
setProducts((prev) => decrementStockLocal(prev, cart));
|
||||
|
||||
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.");
|
||||
@@ -318,6 +339,7 @@ const avatarSrc = useMemo(() => {
|
||||
isFavorite={favorites.has(p.id)}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
count={cart[p.id]?.quantity || 0}
|
||||
ringColor={ringColorForCategory(p.category)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
@@ -6,7 +6,8 @@ export default function ProductCard({
|
||||
onSelect,
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
count = 0, // Menge im Warenkorb
|
||||
count = 0,
|
||||
ringColor = "#22c55e", // Fallback
|
||||
}) {
|
||||
const baseName = (product?.name || '').replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
@@ -62,13 +63,16 @@ export default function ProductCard({
|
||||
};
|
||||
|
||||
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"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ "--tw-ring-color": ringColor }}
|
||||
className="relative rounded-xl overflow-hidden bg-gray-800 cursor-pointer transform transition duration-150 hover:scale-95
|
||||
ring-0 hover:ring-4 ring-offset-0 ring-offset-gray-900
|
||||
focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => onSelect(product)}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(product)}
|
||||
>
|
||||
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onSelect(product)}
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
onError={handleError}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
|
@@ -1,11 +1,20 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api";
|
||||
import { listTopupsAdmin, patchTopupStatus, getUsersLite, getPaypalSettings, updatePaypalSettings, createTopupAdmin } 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 toText = (v) => {
|
||||
if (v == null) return "—";
|
||||
if (typeof v === "object") {
|
||||
try { return JSON.stringify(v); } catch { return "[obj]"; }
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
|
||||
|
||||
const STATUS_LABELS_DE = {
|
||||
pending: "ausstehend",
|
||||
confirmed: "bestätigt",
|
||||
@@ -27,7 +36,6 @@ function Pill({ status }) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Kleiner Avatar (Initialen) */
|
||||
function Initials({ label }) {
|
||||
const txt = String(label || "")
|
||||
@@ -211,6 +219,26 @@ function StatusSelect({ value, onChange }) {
|
||||
|
||||
|
||||
export default function AdminTransactionsPage() {
|
||||
// PayPal-Settings (jetzt legal innerhalb der Component)
|
||||
const [ppOpen, setPpOpen] = useState(false);
|
||||
const [ppMe, setPpMe] = useState("");
|
||||
const [ppReceiver, setPpReceiver] = useState("");
|
||||
const [ppSaving, setPpSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const cfg = await getPaypalSettings();
|
||||
setPpMe(cfg?.paypal_me || "");
|
||||
setPpReceiver(cfg?.paypal_receiver || "");
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
async function savePaypal() {
|
||||
setPpSaving(true);
|
||||
try { await updatePaypalSettings({ paypal_me: ppMe, paypal_receiver: ppReceiver }); }
|
||||
finally { setPpSaving(false); }
|
||||
}
|
||||
|
||||
// Daten
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -351,6 +379,40 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* PayPal-Einstellungen */}
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<button type="button"
|
||||
onClick={() => setPpOpen(o=>!o)}
|
||||
className="w-full flex items-center justify-between text-left">
|
||||
<h2 className="text-white/90 font-semibold">PayPal-Einstellungen</h2>
|
||||
<span className="text-white/60">{ppOpen ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
{ppOpen && (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="block">
|
||||
<div className="text-xs text-white/60 mb-1">PayPal.me Handle (optional)</div>
|
||||
<input value={ppMe} onChange={e=>setPpMe(e.target.value)}
|
||||
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
|
||||
placeholder="z. B. Getraenkewart" />
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<div className="text-xs text-white/60 mb-1">Geschäfts-E-Mail für Webscr/IPN</div>
|
||||
<input value={ppReceiver} onChange={e=>setPpReceiver(e.target.value)}
|
||||
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
|
||||
placeholder="kasse@example.org" />
|
||||
</label>
|
||||
<div className="md:col-span-3">
|
||||
<button onClick={savePaypal} disabled={ppSaving}
|
||||
className="px-4 py-2 rounded-xl font-semibold bg-emerald-600/80 hover:bg-emerald-600 text-white shadow disabled:opacity-60">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -402,7 +464,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
|
||||
{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;
|
||||
const ts = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null;
|
||||
return (
|
||||
<tr key={r.id} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4 font-mono">{r.id}</td>
|
||||
@@ -410,7 +472,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
|
||||
<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 font-mono break-all">{toText(r.note)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getCapabilities, getStatsSummary } from "../../api";
|
||||
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap } from "react-icons/fi";
|
||||
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap, FiAlertTriangle } from "react-icons/fi";
|
||||
|
||||
function Panel({ title, icon: Icon, children, accent = "cyan" }) {
|
||||
const accentClasses =
|
||||
@@ -193,6 +193,23 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Leer */}
|
||||
<Panel title="Leer :(" icon={FiAlertTriangle}>
|
||||
<div className="text-white/70 text-sm mb-3">
|
||||
Hier leere Produkte auflisten
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
|
||||
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"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,63 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getAuditLogs, getUsersLite } from "../../api";
|
||||
|
||||
/* ----------------------------- kleine Utils ----------------------------- */
|
||||
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
|
||||
|
||||
function euroDelta(newC, oldC) {
|
||||
if (newC == null || oldC == null) return null;
|
||||
const d = (newC - oldC) / 100;
|
||||
// null/undefined → kein Wert
|
||||
if (newC == null || oldC == null) return "—";
|
||||
// Strings erlauben
|
||||
const n = Number(newC);
|
||||
const o = Number(oldC);
|
||||
if (!Number.isFinite(n) || !Number.isFinite(o)) return "—";
|
||||
const d = (n - o) / 100;
|
||||
return (d >= 0 ? "+" : "") + d.toFixed(2) + " €";
|
||||
}
|
||||
|
||||
|
||||
function fmtDT(v) {
|
||||
if (!v) return "—";
|
||||
if (v == null) return "—_-";
|
||||
// ganze oder dezimale Zahl-Strings → Zahl
|
||||
if (typeof v === "string" && /^\d+(\.\d+)?$/.test(v)) v = Number(v);
|
||||
// Sekunden → ms
|
||||
if (typeof v === "number" && v < 1e12) v = v * 1000;
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString();
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||
}
|
||||
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
|
||||
|
||||
// vereinheitlicht Zeitfelder aus der API
|
||||
function normalizeLog(e) {
|
||||
let t =
|
||||
e.timestamp ?? e.ts ?? e.time ??
|
||||
e.created_at ?? e.createdAt ?? e.created ??
|
||||
e.occurred_at ?? e.date ??
|
||||
(e.meta && e.meta.timestamp) ?? null;
|
||||
|
||||
if (t == null) return { ...e, timestamp: null };
|
||||
|
||||
// Zahl-String (Sekunden oder ms, ggf. mit Dezimalen)
|
||||
if (typeof t === "string" && /^\d+(\.\d+)?$/.test(t)) {
|
||||
const n = Number(t);
|
||||
return { ...e, timestamp: n < 1e12 ? n * 1000 : n };
|
||||
}
|
||||
|
||||
if (typeof t === "number") {
|
||||
return { ...e, timestamp: t < 1e12 ? t * 1000 : t };
|
||||
}
|
||||
|
||||
if (typeof t === "string") {
|
||||
let s = t.trim();
|
||||
s = s.replace(" ", "T"); // "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM:SS"
|
||||
s = s.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
|
||||
s = s.replace(/([+-]\d{2})$/, "$1:00"); // +00 → +00:00
|
||||
s = s.replace(/(\.\d{3})\d+/, "$1"); // Mikrosekunden auf ms kürzen
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(s)) s += "Z"; // naive → UTC
|
||||
return { ...e, timestamp: s };
|
||||
}
|
||||
|
||||
return { ...e, timestamp: null };
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------- NiceDropdown (inline) ------------------------ */
|
||||
|
||||
@@ -48,7 +92,7 @@ function NiceDropdown({
|
||||
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",
|
||||
"w-full bg-white/10 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
|
||||
)}
|
||||
@@ -140,7 +184,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
|
||||
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"
|
||||
className="w-full bg-white/10- 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}
|
||||
@@ -155,7 +199,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
|
||||
>
|
||||
<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"
|
||||
className="w-full px-3 py-2 mb-2 rounded-lg bg-white/10 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)}
|
||||
@@ -231,7 +275,7 @@ export default function LogsPage() {
|
||||
date_from,
|
||||
date_to,
|
||||
});
|
||||
const arr = Array.isArray(data) ? data : (data.items || []);
|
||||
const arr = (Array.isArray(data) ? data : (data.items || [])).map(normalizeLog);
|
||||
setRows((prev) => (reset ? arr : [...prev, ...arr]));
|
||||
setHasMore(arr.length === LIMIT);
|
||||
setOffset(pageOffset + arr.length);
|
||||
@@ -296,14 +340,14 @@ export default function LogsPage() {
|
||||
/>
|
||||
|
||||
<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"
|
||||
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-white/10 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"
|
||||
className="px-3 py-2 rounded-xl bg-white/10 border border-gray-600 hover:bg-gray-700 text-gray-100"
|
||||
onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }}
|
||||
>
|
||||
Zurücksetzen
|
||||
@@ -325,11 +369,13 @@ export default function LogsPage() {
|
||||
<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 font-mono">
|
||||
{fmtDT(r.timestamp ?? r.created_at ?? r.ts ?? r.time ?? r.date)}
|
||||
</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>
|
||||
<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 && (
|
||||
|
@@ -7,6 +7,8 @@ import {
|
||||
updateProduct,
|
||||
} from "../../api";
|
||||
import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2);
|
||||
@@ -160,7 +162,6 @@ const filtered = useMemo(() => {
|
||||
setReassignInfo(null);
|
||||
}
|
||||
|
||||
// Persistentes Speichern aus dem Edit-Dialog
|
||||
async function saveEditedProduct(updated) {
|
||||
const payload = {
|
||||
name: updated.name,
|
||||
@@ -170,12 +171,16 @@ const filtered = useMemo(() => {
|
||||
supplier_number: updated.supplier_number ?? null,
|
||||
stock: updated.stock,
|
||||
is_active: updated.is_active,
|
||||
// NEU:
|
||||
pack_size: Math.max(1, Number(updated.pack_size) || 1),
|
||||
purchase_price_cents: Number(updated.purchase_price_cents) || 0,
|
||||
};
|
||||
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>;
|
||||
|
||||
@@ -340,33 +345,63 @@ const filtered = useMemo(() => {
|
||||
/* ---------- Sub-Komponenten ---------- */
|
||||
|
||||
function Modal({ title, children, onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
// ESC schließt + Body-Scroll locken
|
||||
useEffect(() => {
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const node = (
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
{/* Backdrop */}
|
||||
<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>
|
||||
{/* Centering-Layer */}
|
||||
<div className="absolute inset-0 p-4 flex items-center justify-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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 className="p-5 text-white/80">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(node, document.body);
|
||||
}
|
||||
|
||||
|
||||
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)} €`} />
|
||||
{/* NEU: Einkaufspreis & Pack-Größe */}
|
||||
{"purchase_price_cents" in item && (
|
||||
<InfoRow label="Einkaufspreis" value={`${euro(item.purchase_price_cents)} €`} />
|
||||
)}
|
||||
{"pack_size" in item && (
|
||||
<InfoRow label="Pack-Größe" value={displayVal(item.pack_size)} />
|
||||
)}
|
||||
<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)} />
|
||||
@@ -376,6 +411,7 @@ function DetailView({ item }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function InfoRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/10 py-2">
|
||||
@@ -392,6 +428,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
id: item.id,
|
||||
name: item.name ?? "",
|
||||
price_cents: Number(item.price_cents ?? 0),
|
||||
// NEU:
|
||||
purchase_price_cents: Number(item.purchase_price_cents ?? 0),
|
||||
pack_size: Number(item.pack_size ?? 1),
|
||||
category: item.category ?? "",
|
||||
stock: item.stock ?? 0,
|
||||
volume_ml: item.volume_ml ?? "",
|
||||
@@ -409,6 +448,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
...item,
|
||||
...form,
|
||||
price_cents: Number(form.price_cents) || 0,
|
||||
// NEU:
|
||||
purchase_price_cents: Number(form.purchase_price_cents) || 0,
|
||||
pack_size: Math.max(1, Number(form.pack_size) || 1),
|
||||
stock: form.stock === "" ? null : Number(form.stock),
|
||||
volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml),
|
||||
});
|
||||
@@ -434,7 +476,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Field label="Preis (Cent)">
|
||||
<Field label="Verkaufspreis (Cent)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
@@ -443,6 +485,27 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Einkaufspreis (Cent)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.purchase_price_cents}
|
||||
onChange={(e) => set("purchase_price_cents", e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Pack-Größe (Stk/Pack)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
|
||||
value={form.pack_size}
|
||||
onChange={(e) => set("pack_size", e.target.value)}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Field label="Volumen (ml)">
|
||||
<input
|
||||
type="number"
|
||||
@@ -461,9 +524,6 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
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"
|
||||
@@ -471,7 +531,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
onChange={(e) => set("supplier_number", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Aktiv">
|
||||
<Switch
|
||||
checked={!!form.is_active}
|
||||
@@ -480,15 +542,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
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">
|
||||
@@ -510,6 +564,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
|
@@ -15,7 +15,6 @@ import {
|
||||
} 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;
|
||||
@@ -29,7 +28,7 @@ function parseEuroToCents(input) {
|
||||
function genCode5() {
|
||||
return String(Math.floor(Math.random() * 100000)).padStart(5, "0");
|
||||
}
|
||||
function buildPaypalUrl(amountCents, code) {
|
||||
function buildPaypalUrl(amountCents, code, topupId) {
|
||||
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();
|
||||
@@ -45,6 +44,10 @@ function buildPaypalUrl(amountCents, code) {
|
||||
amount: amountEuro,
|
||||
item_name: `Bacchus Top-Up ${code || ""}`,
|
||||
no_note: "1",
|
||||
custom: `topup:${topupId || ""}|code:${code || ""}`,
|
||||
notify_url: `${window.location.origin.replace(/\/$/, "")}/api/paypal/ipn`,
|
||||
return: `${window.location.origin}/management/transactions?ok=1`,
|
||||
cancel_return: `${window.location.origin}/management/transactions?canceled=1`,
|
||||
});
|
||||
return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`;
|
||||
}
|
||||
@@ -134,6 +137,9 @@ export default function TransactionPage() {
|
||||
const [err, setErr] = useState(null);
|
||||
const [balanceCents, setBalanceCents] = useState(null);
|
||||
|
||||
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
const [topupId, setTopupId] = useState(null);
|
||||
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -195,8 +201,8 @@ export default function TransactionPage() {
|
||||
|
||||
const readyToSave = amountCents != null && amountCents > 0;
|
||||
const paypalUrl = useMemo(
|
||||
() => (saved ? buildPaypalUrl(amountCents, code) : null),
|
||||
[saved, amountCents, code]
|
||||
() => (saved ? buildPaypalUrl(amountCents, code, topupId) : null),
|
||||
[saved, amountCents, code, topupId]
|
||||
);
|
||||
|
||||
async function onSave() {
|
||||
@@ -206,7 +212,8 @@ export default function TransactionPage() {
|
||||
setCode(newCode);
|
||||
setCopied(false);
|
||||
try {
|
||||
await createTopup(amountCents, newCode); // speichert Top-up + Code
|
||||
const created = await createTopup(amountCents, newCode);
|
||||
setTopupId(created?.id ?? null);
|
||||
await reloadTx();
|
||||
} catch (e) {
|
||||
setTxErr(e?.message || "Top-up konnte nicht angelegt werden.");
|
||||
@@ -230,7 +237,6 @@ export default function TransactionPage() {
|
||||
<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 */}
|
||||
|
@@ -541,12 +541,7 @@ async function openViewer(id) {
|
||||
</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 && (
|
||||
@@ -886,22 +881,36 @@ function Th({ children, onClick, active, order }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, type = "text", maxLength, placeholder }) {
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
maxLength,
|
||||
placeholder,
|
||||
inputClassName = "",
|
||||
}) {
|
||||
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}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
className={cx(
|
||||
"w-full px-3 py-2 rounded-xl border bg-gray-800 text-gray-100",
|
||||
"border-gray-600 placeholder:text-gray-400",
|
||||
"focus:outline-none focus:ring-2 focus:ring-gray-400/40",
|
||||
inputClassName
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function SelectField({ label, value, onChange, options }) {
|
||||
return (
|
||||
<label className="block">
|
||||
|
@@ -12,4 +12,5 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true
|
||||
}
|
||||
});
|
||||
|
Reference in New Issue
Block a user