This commit is contained in:
2025-09-28 19:13:01 +02:00
parent 49edf780b5
commit 541ecb48f2
67 changed files with 5176 additions and 5008 deletions

View File

@@ -1 +0,0 @@
VITE_API_URL=http://localhost:8080

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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}`);
}

View File

@@ -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>
))}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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">

View File

@@ -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 */}

View File

@@ -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">

View File

@@ -12,4 +12,5 @@ export default {
},
},
plugins: [],
}
}

View File

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