feat: add email notifications, order references, stock badges, and sale flags

- Add customer email field and PayPal payment reminder emails with order reference (HEL-YEAR-ID format)
- Display stock availability with color-coded badges (available/low/unavailable)
- Add sale/clearance flag with animated red badge overlay
- Implement automatic fallback placeholder for missing/broken product images
- Add email column to order management view

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 13:56:16 +01:00
parent 99ecea1de9
commit 832aaf2b05
7 changed files with 417 additions and 55 deletions

View File

@@ -41,7 +41,6 @@ Für Bestellungen per Mail zusätzlich:
Optional:
- `APP_API_KEY` Schutz fuer `/wawi/api/bestand` und `/wawi/order`
- `COOKIE_SECURE` `0` fuer http lokal, `1` fuer https
- `PAYPAL_ACCOUNT` PayPal-E-Mail für Bestellformular (z.B. `paypal@beispiel.de`)
## Benutzerverwaltung
Beim ersten Start wird **ein Admin** aus ENV erzeugt:
@@ -60,12 +59,10 @@ python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
## LiveShopAnsicht (index.html)
`index.html` lädt den Bestand aus der WaWiApp:
- APIProxy: `/wawi/proxy/bestand`
- Konfiguration: `/wawi/config` (lädt PayPal-Konto aus ENV)
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWiApp unter `/wawi` erreichbar ist.
Wenn du Bestellungen direkt aus `index.html` abschickst, muss der `X-Order-Key` bzw. `?key=` dem `APP_API_KEY` entsprechen.
Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""`. Trage dort deinen Key ein (oder leer lassen, wenn kein API-Key erforderlich).
Das PayPal-Konto wird automatisch aus der ENV-Variable `PAYPAL_ACCOUNT` geladen.
Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""` (neben der Formularlogik). Trage dort deinen Key ein.
## Umgebungsvariablen (ENV)
**Pflicht/Empfohlen für Produktion**
@@ -83,7 +80,6 @@ Das PayPal-Konto wird automatisch aus der ENV-Variable `PAYPAL_ACCOUNT` geladen.
**Optional**
- `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order`
- `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http
- `PAYPAL_ACCOUNT` PayPal-E-Mail-Adresse für Bestellungen (wird im Bestellformular angezeigt)
## Deployment (systemd + Gunicorn)
1) App nach `/var/www/hellas/wawi` kopieren
@@ -116,7 +112,6 @@ Environment="SMTP_PASS=dein_pass"
Environment="SMTP_FROM=bestand@example.com"
Environment="ORDER_TO=admin@example.com, zweite@example.com"
Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Environment="PAYPAL_ACCOUNT=dein-paypal@beispiel.de"
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
Restart=always

View File

@@ -133,6 +133,50 @@
}
.card-price { color: var(--accent); font-weight: 800; font-size: 14px; }
.card-sub { color: var(--muted); font-size: 12px; }
.stock-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
margin-top: 6px;
}
.stock-badge.available {
background: rgba(123, 213, 141, 0.15);
color: var(--ok);
border: 1px solid rgba(123, 213, 141, 0.3);
}
.stock-badge.low {
background: rgba(243, 213, 42, 0.15);
color: var(--accent);
border: 1px solid rgba(243, 213, 42, 0.3);
}
.stock-badge.unavailable {
background: rgba(255, 107, 125, 0.15);
color: var(--bad);
border: 1px solid rgba(255, 107, 125, 0.3);
}
.sale-badge {
position: absolute;
top: 8px;
left: 8px;
background: linear-gradient(135deg, #ff0000, #cc0000);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
box-shadow: 0 4px 12px rgba(255, 0, 0, 0.4);
z-index: 10;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.card-tile { position: relative; }
.card-actions { padding: 0 12px 12px; display: flex; justify-content: flex-end; }
.detail-btn { width: 100%; }
.size-list { display: grid; gap: 8px; padding: 10px 2px 2px; }
@@ -422,6 +466,9 @@
<label>Handy
<input id="fHandy" name="handy" required />
</label>
<label>E-Mail
<input id="fEmail" name="email" type="email" required />
</label>
<label>Mannschaft
<input id="fMannschaft" name="mannschaft" placeholder="z. B. U12" required />
</label>
@@ -438,6 +485,10 @@
<div id="paypalInfo" style="margin-left: 28px; margin-bottom: 12px; font-size: 0.95em; color: #1e40af;">
Bitte sende den Betrag über PayPal an: <strong id="paypalAccount"></strong>
</div>
<div style="margin-left: 28px; margin-bottom: 12px; padding: 10px; background: #fef3c7; border: 2px solid #f59e0b; border-radius: 6px; font-size: 0.9em; color: #92400e;">
<strong>⚠️ WICHTIG:</strong> Bitte unbedingt die Option <strong>"Familie & Freunde"</strong> wählen!<br>
Bei normalen PayPal-Zahlungen fallen zusätzliche Gebühren an.
</div>
<label style="display: flex; align-items: center;">
<input type="radio" name="payment_method" value="bar" style="margin-right: 8px;" />
<span><strong>Bar bei Abholung</strong></span>
@@ -535,18 +586,41 @@ function render(items) {
const priceText = price > 0 ? `${price.toFixed(2)}` : "Preis auf Anfrage";
const img = (item.bild_url || "").trim();
// Verfügbarkeit prüfen
const totalStock = Number(t.gezaehlt) || 0;
let stockBadge = '';
let stockClass = '';
if (totalStock === 0) {
stockBadge = '<div class="stock-badge unavailable">⛔ Nicht verfügbar</div>';
stockClass = 'unavailable';
} else if (totalStock <= 5) {
stockBadge = `<div class="stock-badge low">⚠️ Nur noch ${totalStock} verfügbar</div>`;
stockClass = 'low';
} else {
stockBadge = `<div class="stock-badge available">✓ Verfügbar (${totalStock})</div>`;
stockClass = 'available';
}
// Sale-Badge anzeigen
const saleBadge = item.sale ? '<div class="sale-badge">SALE 🔥</div>' : '';
return `
<div class="card-tile">
${saleBadge}
<div class="card-media">
${img ? `<button class="thumb-btn" data-img="${img}" aria-label="Bild vergrößern" title="Bild vergrößern" style="width:100%;height:100%;background-image:url('${img}');background-size:cover;background-position:center;border:0;cursor:pointer;"></button>` : "Bild"}
${img ? `<button class="thumb-btn" data-img="${img}" aria-label="Bild vergrößern" title="Bild vergrößern" style="width:100%;height:100%;border:0;cursor:pointer;padding:0;position:relative;overflow:hidden;"><img src="${img}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" style="width:100%;height:100%;object-fit:cover;display:block;"><div style="display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:linear-gradient(135deg, rgba(42,138,138,.15), rgba(18,45,70,.2));font-size:40px;opacity:0.5;">📦</div></button>` : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg, rgba(42,138,138,.15), rgba(18,45,70,.2));font-size:40px;opacity:0.5;">📦</div>`}
</div>
<div class="card-body">
<div class="card-title">${item.artikel}</div>
<div class="card-price">${priceText}</div>
<div class="card-sub">${item.rows.length} Größen</div>
${stockBadge}
</div>
<div class="card-actions">
<button class="order-btn detail-btn" data-artikel="${item.artikel}" data-preis="${priceText}" data-img="${img}">Bestellen / Details</button>
<button class="order-btn detail-btn" data-artikel="${item.artikel}" data-preis="${priceText}" data-img="${img}" ${totalStock === 0 ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>
${totalStock === 0 ? 'Nicht verfügbar' : 'Bestellen / Details'}
</button>
</div>
</div>
`;
@@ -607,6 +681,9 @@ document.addEventListener("click", (e) => {
}
const detailBtn = e.target.closest(".detail-btn");
if (detailBtn) {
// Verhindere Klick auf disabled Button
if (detailBtn.disabled) return;
const artikel = detailBtn.dataset.artikel;
const item = DATA_MAP.get(artikel);
if (!item) return;
@@ -616,12 +693,27 @@ document.addEventListener("click", (e) => {
document.getElementById("detailTitle").textContent = artikel;
document.getElementById("detailPrice").textContent = detailBtn.dataset.preis || "Preis auf Anfrage";
document.getElementById("detailSizes").textContent = `${item.rows.length} Größen`;
const list = item.rows.map(r => `
<div class="size-row">
<div class="size-meta"><strong>${fmt(r.groesse)}</strong> · Bestand: ${fmt(r.gezaehlt)}</div>
${(Number(r.gezaehlt) || 0) > 0 ? `<button class="order-btn" data-artikel="${item.artikel}" data-groesse="${fmt(r.groesse)}">Bestellen</button>` : ""}
</div>
`).join("");
const list = item.rows.map(r => {
const stock = Number(r.gezaehlt) || 0;
let stockInfo = '';
if (stock === 0) {
stockInfo = '<span class="stock-badge unavailable">Nicht verfügbar</span>';
} else if (stock <= 3) {
stockInfo = `<span class="stock-badge low">Nur noch ${stock} verfügbar</span>`;
} else {
stockInfo = `<span class="stock-badge available">Verfügbar (${stock})</span>`;
}
return `
<div class="size-row">
<div class="size-meta">
<strong>${fmt(r.groesse)}</strong> · ${stockInfo}
</div>
${stock > 0 ? `<button class="order-btn" data-artikel="${item.artikel}" data-groesse="${fmt(r.groesse)}">Bestellen</button>` : '<span style="color: var(--muted); font-size: 11px;"></span>'}
</div>
`;
}).join("");
document.getElementById("detailSizeList").innerHTML = list;
document.getElementById("detailModal").classList.add("open");
return;

View File

@@ -166,6 +166,7 @@ def init_db() -> None:
db.commit()
ensure_price_column(db)
ensure_image_column(db)
ensure_sale_column(db)
ensure_orders_columns(db)
ensure_payment_columns(db)
ensure_indexes(db)
@@ -190,6 +191,16 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
db.commit()
def ensure_sale_column(db: sqlite3.Connection) -> None:
"""Fügt sale-Spalte nachträglich hinzu."""
cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "sale" for c in cols):
return
db.execute("ALTER TABLE items ADD COLUMN sale INTEGER NOT NULL DEFAULT 0")
db.commit()
logger.info("Sale-Spalte für items erstellt")
def ensure_orders_columns(db: sqlite3.Connection) -> None:
"""Sorgt für alle nachträglich eingeführten OrdersSpalten."""
cols = db.execute("PRAGMA table_info(orders)").fetchall()
@@ -206,6 +217,8 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
db.execute("ALTER TABLE orders ADD COLUMN canceled_by TEXT")
if "canceled_at" not in names:
db.execute("ALTER TABLE orders ADD COLUMN canceled_at TEXT")
if "email" not in names:
db.execute("ALTER TABLE orders ADD COLUMN email TEXT")
db.commit()
@@ -393,6 +406,7 @@ def index():
q = (request.args.get("q") or "").strip()
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
direction = (request.args.get("dir") or "desc").strip().lower()
current_year = datetime.now().year
allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"}
if sort not in allowed:
@@ -400,7 +414,7 @@ def index():
if direction not in {"asc", "desc"}:
direction = "desc"
params: list[Any] = []
params: list[Any] = [str(current_year)]
where = ""
if q:
where = "WHERE artikel LIKE ? OR groesse LIKE ?"
@@ -408,9 +422,20 @@ def index():
params.extend([like, like])
sql = f"""
SELECT *
SELECT
items.*,
COALESCE(SUM(CASE
WHEN orders.done = 1
AND orders.canceled = 0
AND orders.completed_at IS NOT NULL
AND substr(orders.completed_at, 1, 4) = ?
THEN orders.menge
ELSE 0
END), 0) AS verkaeufe_aktuell
FROM items
LEFT JOIN orders ON items.artikel = orders.artikel AND items.groesse = orders.groesse
{where}
GROUP BY items.id
ORDER BY {sort} {direction}, artikel ASC, groesse ASC
"""
rows = get_db().execute(sql, params).fetchall()
@@ -433,6 +458,7 @@ def index():
total=total,
total_bestand=total_bestand,
open_orders=open_orders,
current_year=current_year,
)
@@ -448,6 +474,7 @@ def new_item():
uploaded = save_upload(request.files.get("bild_file"))
if uploaded:
bild_url = uploaded
sale = 1 if request.form.get("sale") else 0
soll = int(request.form.get("soll") or 0)
gezaehlt = int(request.form.get("gezaehlt") or 0)
verkaeufe = int(request.form.get("verkaeufe") or 0)
@@ -456,12 +483,12 @@ def new_item():
db = get_db()
db.execute(
"""
INSERT INTO items (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO items (artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
(artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
)
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
db.execute("UPDATE items SET preis = ?, bild_url = ?, sale = ? WHERE artikel = ?", (preis, bild_url, sale, artikel))
db.commit()
return redirect(url_for("bp.index"))
@@ -485,6 +512,7 @@ def edit_item(item_id: int):
uploaded = save_upload(request.files.get("bild_file"))
if uploaded:
bild_url = uploaded
sale = 1 if request.form.get("sale") else 0
soll = int(request.form.get("soll") or 0)
gezaehlt = int(request.form.get("gezaehlt") or 0)
verkaeufe = int(request.form.get("verkaeufe") or 0)
@@ -492,12 +520,12 @@ def edit_item(item_id: int):
db.execute(
"""
UPDATE items
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, sale = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
WHERE id = ?
""",
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), item_id),
(artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, now_iso(), item_id),
)
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
db.execute("UPDATE items SET preis = ?, bild_url = ?, sale = ? WHERE artikel = ?", (preis, bild_url, sale, artikel))
db.commit()
return redirect(url_for("bp.index"))
@@ -677,7 +705,7 @@ def build_bestand() -> list[dict]:
"""Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht."""
rows = get_db().execute(
"""
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
SELECT artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe
FROM items
ORDER BY artikel, groesse
"""
@@ -690,12 +718,15 @@ def build_bestand() -> list[dict]:
continue
item = data.setdefault(
artikel,
{"artikel": artikel, "preis": 0, "bild_url": "", "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
{"artikel": artikel, "preis": 0, "bild_url": "", "sale": 0, "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
)
if not item["preis"]:
item["preis"] = float(r["preis"] or 0)
if not item["bild_url"]:
item["bild_url"] = (r["bild_url"] or "").strip()
# Sale-Status (1 wenn mindestens eine Größe sale=1 hat)
if r["sale"]:
item["sale"] = 1
soll = int(r["soll"] or 0)
gezaehlt = int(r["gezaehlt"] or 0)
verkaeufe = int(r["verkaeufe"] or 0)
@@ -743,7 +774,7 @@ def order():
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json(silent=True) or request.form
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
required = ["name", "handy", "email", "mannschaft", "artikel", "groesse", "menge"]
if any(not (data.get(k) or "").strip() for k in required):
return jsonify({"error": "Pflichtfelder fehlen."}), 400
if int(data.get("menge") or 0) <= 0:
@@ -754,14 +785,15 @@ def order():
payment_method = "bar"
db = get_db()
db.execute(
cursor = db.execute(
"""
INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
INSERT INTO orders (name, handy, email, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
""",
(
data.get("name"),
data.get("handy"),
data.get("email"),
data.get("mannschaft"),
data.get("artikel"),
data.get("groesse"),
@@ -771,8 +803,9 @@ def order():
payment_method,
),
)
order_id = cursor.lastrowid
db.commit()
logger.info(f"Neue Bestellung: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
logger.info(f"Neue Bestellung #{order_id}: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
smtp_host = os.environ.get("SMTP_HOST")
@@ -821,6 +854,69 @@ def order():
threading.Thread(target=_send, daemon=True).start()
# PayPal payment reminder to customer
if payment_method == "paypal":
customer_email = data.get("email", "").strip()
if customer_email and smtp_host and smtp_user and smtp_pass:
paypal_account = os.environ.get("PAYPAL_ACCOUNT", "")
customer_msg = EmailMessage()
customer_msg["Subject"] = "Zahlungshinweis für deine Hellas-Bestellung"
customer_msg["From"] = smtp_from
customer_msg["To"] = customer_email
# Calculate total price (need to get item price from DB)
item = db.execute(
"SELECT preis FROM items WHERE artikel = ? AND groesse = ? LIMIT 1",
(data.get("artikel"), data.get("groesse"))
).fetchone()
preis = float(item["preis"]) if item else 0
menge = int(data.get("menge") or 0)
total = preis * menge
# Referenznummer im Format HEL-2026-123
year = datetime.now().year
ref_number = f"HEL-{year}-{order_id}"
customer_body = (
f"Hallo {data.get('name')},\n\n"
f"vielen Dank für deine Bestellung bei Hellas 1899!\n\n"
f"Bestelldetails:\n"
f"• Bestellnummer: {ref_number}\n"
f"• Artikel: {data.get('artikel')}\n"
f"• Größe: {data.get('groesse')}\n"
f"• Menge: {menge}\n"
f"• Preis: {total:.2f}\n\n"
f"⚠️ WICHTIG - Zahlungshinweis:\n"
f"Du hast PayPal als Zahlungsart gewählt. Bitte überweise den Betrag an:\n\n"
f"PayPal-Konto: {paypal_account}\n"
f"Betrag: {total:.2f}\n"
f"Verwendungszweck: {ref_number}\n\n"
f"WICHTIG: Bitte wähle unbedingt die Option \"Familie & Freunde\", damit keine zusätzlichen Gebühren anfallen!\n"
f"Bitte gib die Bestellnummer {ref_number} als Verwendungszweck an, damit wir deine Zahlung zuordnen können.\n\n"
f"Sobald der Betrag eingegangen ist, wird deine Bestellung bearbeitet und du wirst benachrichtigt, wenn sie zur Abholung bereit ist.\n\n"
f"Viele Grüße\n"
f"Dein Hellas 1899 Team\n"
)
customer_msg.set_content(customer_body)
def _send_customer():
"""Sendet Zahlungs-Erinnerung an Kunden asynchron."""
try:
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(customer_msg, to_addrs=[customer_email])
logger.info(f"PayPal-Zahlungshinweis gesendet an {customer_email}")
except smtplib.SMTPAuthenticationError as e:
logger.error(f"SMTP-Authentifizierung fehlgeschlagen (Kunden-Email): {e}")
except smtplib.SMTPException as e:
logger.error(f"SMTP-Fehler beim Kunden-Email-Versand: {e}")
except Exception as e:
logger.error(f"Unerwarteter Fehler beim Kunden-Email-Versand: {e}", exc_info=True)
threading.Thread(target=_send_customer, daemon=True).start()
return jsonify({"ok": True})
@@ -831,7 +927,7 @@ def orders():
"""Bestellliste in der Verwaltung."""
rows = get_db().execute(
"""
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status, paid_at, paid_by
SELECT id, name, handy, email, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status, paid_at, paid_by
FROM orders
ORDER BY id DESC
LIMIT 500
@@ -874,11 +970,12 @@ def complete_order(order_id: int):
"""
UPDATE items
SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END,
soll = CASE WHEN soll - ? < 0 THEN 0 ELSE soll - ? END,
verkaeufe = verkaeufe + ?,
updated_at = ?
WHERE artikel = ? AND groesse = ?
""",
(menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
(menge, menge, menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
)
db.execute(
"""
@@ -930,6 +1027,80 @@ def cancel_order(order_id: int):
return redirect(url_for("bp.orders"))
@bp.route("/abrechnung")
@login_required
def abrechnung():
"""Abrechnungs-Ansicht für das aktuelle Jahr."""
db = get_db()
current_year = datetime.now().year
year = request.args.get("year", str(current_year))
# Alle erledigten Bestellungen im gewählten Jahr oder vor 2026
if year == "ALT":
orders = db.execute(
"""
SELECT o.*, i.preis
FROM orders o
LEFT JOIN items i ON o.artikel = i.artikel AND o.groesse = i.groesse
WHERE o.done = 1 AND o.canceled = 0
AND o.completed_at IS NOT NULL
AND substr(o.completed_at, 1, 4) < '2026'
ORDER BY o.completed_at DESC
""",
).fetchall()
else:
orders = db.execute(
"""
SELECT o.*, i.preis
FROM orders o
LEFT JOIN items i ON o.artikel = i.artikel AND o.groesse = i.groesse
WHERE o.done = 1 AND o.canceled = 0
AND o.completed_at IS NOT NULL
AND substr(o.completed_at, 1, 4) = ?
ORDER BY o.completed_at DESC
""",
(year,),
).fetchall()
# Gesamtumsatz berechnen
total_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in orders)
total_items = sum(o["menge"] or 0 for o in orders)
# Nach Zahlungsart gruppieren
paypal_orders = [o for o in orders if o["payment_method"] == "paypal"]
bar_orders = [o for o in orders if o["payment_method"] == "bar"]
paypal_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in paypal_orders)
bar_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in bar_orders)
# Nach Artikel gruppieren
artikel_stats = {}
for o in orders:
key = o["artikel"]
if key not in artikel_stats:
artikel_stats[key] = {"menge": 0, "umsatz": 0}
artikel_stats[key]["menge"] += o["menge"] or 0
artikel_stats[key]["umsatz"] += (o["preis"] or 0) * (o["menge"] or 0)
artikel_list = [
{"artikel": k, "menge": v["menge"], "umsatz": v["umsatz"]}
for k, v in sorted(artikel_stats.items(), key=lambda x: x[1]["umsatz"], reverse=True)
]
return render_template(
"abrechnung.html",
year=year,
current_year=current_year,
orders=orders,
total_revenue=total_revenue,
total_items=total_items,
paypal_count=len(paypal_orders),
bar_count=len(bar_orders),
paypal_revenue=paypal_revenue,
bar_revenue=bar_revenue,
artikel_list=artikel_list,
)
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
@login_required
def reset_user_password(user_id: int):

19
wawi/static/style.css Executable file → Normal file
View File

@@ -198,25 +198,6 @@ input[type="text"], input[type="number"] {
}
.to-top:hover { transform: translateY(-2px); }
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.badge.success {
background: rgba(123, 213, 141, 0.2);
color: var(--ok);
border: 1px solid rgba(123, 213, 141, 0.4);
}
.badge.warning {
background: rgba(243, 213, 42, 0.2);
color: var(--accent);
border: 1px solid rgba(243, 213, 42, 0.4);
}
@media (max-width: 720px) {
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn.small { padding: 6px 8px; }

116
wawi/templates/abrechnung.html Executable file
View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block content %}
<div class="toolbar">
<h2 style="margin: 0; font-size: 20px;">Abrechnung {{ year }}</h2>
<form class="search" method="get" action="{{ url_for('bp.abrechnung') }}">
<select name="year">
{% for y in range(current_year, 2025, -1) %}
<option value="{{ y }}" {% if year == y|string %}selected{% endif %}>{{ y }}</option>
{% endfor %}
<option value="ALT" {% if year == "ALT" %}selected{% endif %}>Vor 2026 (gesamt)</option>
</select>
<button class="btn" type="submit">Anzeigen</button>
</form>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 20px;">
<div class="stat">
<div class="label">Gesamtumsatz</div>
<div class="value">{{ "%.2f"|format(total_revenue) }} €</div>
</div>
<div class="stat">
<div class="label">Verkaufte Artikel</div>
<div class="value">{{ total_items }}</div>
</div>
<div class="stat">
<div class="label">PayPal ({{ paypal_count }}x)</div>
<div class="value">{{ "%.2f"|format(paypal_revenue) }} €</div>
</div>
<div class="stat">
<div class="label">Bar ({{ bar_count }}x)</div>
<div class="value">{{ "%.2f"|format(bar_revenue) }} €</div>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<div style="padding: 16px; border-bottom: 1px solid rgba(255,255,255,.08);">
<h3 style="margin: 0; font-size: 16px;">Umsatz nach Artikel</h3>
</div>
<table>
<thead>
<tr>
<th>Artikel</th>
<th>Verkaufte Menge</th>
<th>Umsatz</th>
</tr>
</thead>
<tbody>
{% if artikel_list %}
{% for a in artikel_list %}
<tr>
<td>{{ a.artikel }}</td>
<td>{{ a.menge }}</td>
<td>{{ "%.2f"|format(a.umsatz) }} €</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="3" class="empty">Keine Daten für {{ year }}.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="card">
<div style="padding: 16px; border-bottom: 1px solid rgba(255,255,255,.08);">
<h3 style="margin: 0; font-size: 16px;">Alle erledigten Bestellungen ({{ orders|length }})</h3>
</div>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Name</th>
<th>Mannschaft</th>
<th>Artikel</th>
<th>Größe</th>
<th>Menge</th>
<th>Preis/Stk</th>
<th>Gesamt</th>
<th>Zahlungsart</th>
<th>Zahlung</th>
</tr>
</thead>
<tbody>
{% if orders %}
{% for o in orders %}
<tr>
<td>{{ o.completed_at }}</td>
<td>{{ o.name }}</td>
<td>{{ o.mannschaft }}</td>
<td>{{ o.artikel }}</td>
<td>{{ o.groesse }}</td>
<td>{{ o.menge }}</td>
<td>{{ "%.2f"|format(o.preis or 0) }} €</td>
<td><strong>{{ "%.2f"|format((o.preis or 0) * (o.menge or 0)) }} €</strong></td>
<td>
{% if o.payment_method == "paypal" %}PayPal
{% else %}Bar
{% endif %}
</td>
<td>
{% if o.payment_status == "paid" %}<span class="badge success">Bezahlt</span>
{% else %}<span class="badge warning">Unbezahlt</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="10" class="empty">Keine erledigten Bestellungen für {{ year }}.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -25,6 +25,10 @@
Bild hochladen (optional)
<input type="file" name="bild_file" accept="image/*" />
</label>
<label style="display: flex; align-items: center; gap: 8px; padding-top: 8px;">
<input type="checkbox" name="sale" value="1" {% if item and item.sale %}checked{% endif %} style="width: auto; height: 18px;" />
<span style="color: var(--text);">Sale / Abverkauf 🔥</span>
</label>
<label>
Soll
<input type="number" name="soll" min="0" value="{{ item.soll if item else 0 }}" />

View File

@@ -16,6 +16,7 @@
<th>Datum</th>
<th>Name</th>
<th>Handy</th>
<th>E-Mail</th>
<th>Mannschaft</th>
<th>Artikel</th>
<th>Größe</th>
@@ -33,6 +34,7 @@
<td>{{ o.created_at }}</td>
<td>{{ o.name }}</td>
<td>{{ o.handy }}</td>
<td>{{ o.email or "" }}</td>
<td>{{ o.mannschaft }}</td>
<td>{{ o.artikel }}</td>
<td>{{ o.groesse }}</td>
@@ -43,7 +45,8 @@
{% endif %}
</td>
<td>
{% if o.payment_status == "paid" %}<span class="badge success">Bezahlt</span>
{% if o.canceled %}
{% elif o.payment_status == "paid" %}<span class="badge success">Bezahlt</span>
{% else %}<span class="badge warning">Unbezahlt</span>
{% endif %}
</td>
@@ -68,7 +71,7 @@
{% endif %}
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button class="btn small danger" type="submit">Stornieren</button>
<button class="btn small danger" type="submit">Storno</button>
</form>
{% else %}
@@ -76,7 +79,7 @@
</td>
</tr>
<tr class="order-history">
<td colspan="12">
<td colspan="13">
<details class="inline-details">
<summary>Historie</summary>
<div class="history-grid">
@@ -92,7 +95,7 @@
</tr>
{% else %}
<tr>
<td colspan="12" class="empty">Keine Bestellungen.</td>
<td colspan="13" class="empty">Keine Bestellungen.</td>
</tr>
{% endfor %}
</tbody>