new init
This commit is contained in:
@@ -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">
|
||||
|
Reference in New Issue
Block a user