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: Optional:
- `APP_API_KEY` Schutz fuer `/wawi/api/bestand` und `/wawi/order` - `APP_API_KEY` Schutz fuer `/wawi/api/bestand` und `/wawi/order`
- `COOKIE_SECURE` `0` fuer http lokal, `1` fuer https - `COOKIE_SECURE` `0` fuer http lokal, `1` fuer https
- `PAYPAL_ACCOUNT` PayPal-E-Mail für Bestellformular (z.B. `paypal@beispiel.de`)
## Benutzerverwaltung ## Benutzerverwaltung
Beim ersten Start wird **ein Admin** aus ENV erzeugt: 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) ## LiveShopAnsicht (index.html)
`index.html` lädt den Bestand aus der WaWiApp: `index.html` lädt den Bestand aus der WaWiApp:
- APIProxy: `/wawi/proxy/bestand` - 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 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. 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). Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""` (neben der Formularlogik). Trage dort deinen Key ein.
Das PayPal-Konto wird automatisch aus der ENV-Variable `PAYPAL_ACCOUNT` geladen.
## Umgebungsvariablen (ENV) ## Umgebungsvariablen (ENV)
**Pflicht/Empfohlen für Produktion** **Pflicht/Empfohlen für Produktion**
@@ -83,7 +80,6 @@ Das PayPal-Konto wird automatisch aus der ENV-Variable `PAYPAL_ACCOUNT` geladen.
**Optional** **Optional**
- `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order` - `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order`
- `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http - `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) ## Deployment (systemd + Gunicorn)
1) App nach `/var/www/hellas/wawi` kopieren 1) App nach `/var/www/hellas/wawi` kopieren
@@ -116,7 +112,6 @@ Environment="SMTP_PASS=dein_pass"
Environment="SMTP_FROM=bestand@example.com" Environment="SMTP_FROM=bestand@example.com"
Environment="ORDER_TO=admin@example.com, zweite@example.com" Environment="ORDER_TO=admin@example.com, zweite@example.com"
Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 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 ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
Restart=always Restart=always

View File

@@ -133,6 +133,50 @@
} }
.card-price { color: var(--accent); font-weight: 800; font-size: 14px; } .card-price { color: var(--accent); font-weight: 800; font-size: 14px; }
.card-sub { color: var(--muted); font-size: 12px; } .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; } .card-actions { padding: 0 12px 12px; display: flex; justify-content: flex-end; }
.detail-btn { width: 100%; } .detail-btn { width: 100%; }
.size-list { display: grid; gap: 8px; padding: 10px 2px 2px; } .size-list { display: grid; gap: 8px; padding: 10px 2px 2px; }
@@ -422,6 +466,9 @@
<label>Handy <label>Handy
<input id="fHandy" name="handy" required /> <input id="fHandy" name="handy" required />
</label> </label>
<label>E-Mail
<input id="fEmail" name="email" type="email" required />
</label>
<label>Mannschaft <label>Mannschaft
<input id="fMannschaft" name="mannschaft" placeholder="z. B. U12" required /> <input id="fMannschaft" name="mannschaft" placeholder="z. B. U12" required />
</label> </label>
@@ -438,6 +485,10 @@
<div id="paypalInfo" style="margin-left: 28px; margin-bottom: 12px; font-size: 0.95em; color: #1e40af;"> <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> Bitte sende den Betrag über PayPal an: <strong id="paypalAccount"></strong>
</div> </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;"> <label style="display: flex; align-items: center;">
<input type="radio" name="payment_method" value="bar" style="margin-right: 8px;" /> <input type="radio" name="payment_method" value="bar" style="margin-right: 8px;" />
<span><strong>Bar bei Abholung</strong></span> <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 priceText = price > 0 ? `${price.toFixed(2)}` : "Preis auf Anfrage";
const img = (item.bild_url || "").trim(); 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 ` return `
<div class="card-tile"> <div class="card-tile">
${saleBadge}
<div class="card-media"> <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>
<div class="card-body"> <div class="card-body">
<div class="card-title">${item.artikel}</div> <div class="card-title">${item.artikel}</div>
<div class="card-price">${priceText}</div> <div class="card-price">${priceText}</div>
<div class="card-sub">${item.rows.length} Größen</div> <div class="card-sub">${item.rows.length} Größen</div>
${stockBadge}
</div> </div>
<div class="card-actions"> <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>
</div> </div>
`; `;
@@ -607,6 +681,9 @@ document.addEventListener("click", (e) => {
} }
const detailBtn = e.target.closest(".detail-btn"); const detailBtn = e.target.closest(".detail-btn");
if (detailBtn) { if (detailBtn) {
// Verhindere Klick auf disabled Button
if (detailBtn.disabled) return;
const artikel = detailBtn.dataset.artikel; const artikel = detailBtn.dataset.artikel;
const item = DATA_MAP.get(artikel); const item = DATA_MAP.get(artikel);
if (!item) return; if (!item) return;
@@ -616,12 +693,27 @@ document.addEventListener("click", (e) => {
document.getElementById("detailTitle").textContent = artikel; document.getElementById("detailTitle").textContent = artikel;
document.getElementById("detailPrice").textContent = detailBtn.dataset.preis || "Preis auf Anfrage"; document.getElementById("detailPrice").textContent = detailBtn.dataset.preis || "Preis auf Anfrage";
document.getElementById("detailSizes").textContent = `${item.rows.length} Größen`; document.getElementById("detailSizes").textContent = `${item.rows.length} Größen`;
const list = item.rows.map(r => ` const list = item.rows.map(r => {
<div class="size-row"> const stock = Number(r.gezaehlt) || 0;
<div class="size-meta"><strong>${fmt(r.groesse)}</strong> · Bestand: ${fmt(r.gezaehlt)}</div> let stockInfo = '';
${(Number(r.gezaehlt) || 0) > 0 ? `<button class="order-btn" data-artikel="${item.artikel}" data-groesse="${fmt(r.groesse)}">Bestellen</button>` : ""}
</div> if (stock === 0) {
`).join(""); 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("detailSizeList").innerHTML = list;
document.getElementById("detailModal").classList.add("open"); document.getElementById("detailModal").classList.add("open");
return; return;

View File

@@ -166,6 +166,7 @@ def init_db() -> None:
db.commit() db.commit()
ensure_price_column(db) ensure_price_column(db)
ensure_image_column(db) ensure_image_column(db)
ensure_sale_column(db)
ensure_orders_columns(db) ensure_orders_columns(db)
ensure_payment_columns(db) ensure_payment_columns(db)
ensure_indexes(db) ensure_indexes(db)
@@ -190,6 +191,16 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
db.commit() 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: def ensure_orders_columns(db: sqlite3.Connection) -> None:
"""Sorgt für alle nachträglich eingeführten OrdersSpalten.""" """Sorgt für alle nachträglich eingeführten OrdersSpalten."""
cols = db.execute("PRAGMA table_info(orders)").fetchall() 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") db.execute("ALTER TABLE orders ADD COLUMN canceled_by TEXT")
if "canceled_at" not in names: if "canceled_at" not in names:
db.execute("ALTER TABLE orders ADD COLUMN canceled_at TEXT") 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() db.commit()
@@ -393,6 +406,7 @@ def index():
q = (request.args.get("q") or "").strip() q = (request.args.get("q") or "").strip()
sort = (request.args.get("sort") or "gezaehlt").strip().lower() sort = (request.args.get("sort") or "gezaehlt").strip().lower()
direction = (request.args.get("dir") or "desc").strip().lower() direction = (request.args.get("dir") or "desc").strip().lower()
current_year = datetime.now().year
allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"} allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"}
if sort not in allowed: if sort not in allowed:
@@ -400,7 +414,7 @@ def index():
if direction not in {"asc", "desc"}: if direction not in {"asc", "desc"}:
direction = "desc" direction = "desc"
params: list[Any] = [] params: list[Any] = [str(current_year)]
where = "" where = ""
if q: if q:
where = "WHERE artikel LIKE ? OR groesse LIKE ?" where = "WHERE artikel LIKE ? OR groesse LIKE ?"
@@ -408,9 +422,20 @@ def index():
params.extend([like, like]) params.extend([like, like])
sql = f""" 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 FROM items
LEFT JOIN orders ON items.artikel = orders.artikel AND items.groesse = orders.groesse
{where} {where}
GROUP BY items.id
ORDER BY {sort} {direction}, artikel ASC, groesse ASC ORDER BY {sort} {direction}, artikel ASC, groesse ASC
""" """
rows = get_db().execute(sql, params).fetchall() rows = get_db().execute(sql, params).fetchall()
@@ -433,6 +458,7 @@ def index():
total=total, total=total,
total_bestand=total_bestand, total_bestand=total_bestand,
open_orders=open_orders, open_orders=open_orders,
current_year=current_year,
) )
@@ -448,6 +474,7 @@ def new_item():
uploaded = save_upload(request.files.get("bild_file")) uploaded = save_upload(request.files.get("bild_file"))
if uploaded: if uploaded:
bild_url = uploaded bild_url = uploaded
sale = 1 if request.form.get("sale") else 0
soll = int(request.form.get("soll") or 0) soll = int(request.form.get("soll") or 0)
gezaehlt = int(request.form.get("gezaehlt") or 0) gezaehlt = int(request.form.get("gezaehlt") or 0)
verkaeufe = int(request.form.get("verkaeufe") or 0) verkaeufe = int(request.form.get("verkaeufe") or 0)
@@ -456,12 +483,12 @@ def new_item():
db = get_db() db = get_db()
db.execute( db.execute(
""" """
INSERT INTO items (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, created_at, updated_at) INSERT INTO items (artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 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() db.commit()
return redirect(url_for("bp.index")) return redirect(url_for("bp.index"))
@@ -485,6 +512,7 @@ def edit_item(item_id: int):
uploaded = save_upload(request.files.get("bild_file")) uploaded = save_upload(request.files.get("bild_file"))
if uploaded: if uploaded:
bild_url = uploaded bild_url = uploaded
sale = 1 if request.form.get("sale") else 0
soll = int(request.form.get("soll") or 0) soll = int(request.form.get("soll") or 0)
gezaehlt = int(request.form.get("gezaehlt") or 0) gezaehlt = int(request.form.get("gezaehlt") or 0)
verkaeufe = int(request.form.get("verkaeufe") or 0) verkaeufe = int(request.form.get("verkaeufe") or 0)
@@ -492,12 +520,12 @@ def edit_item(item_id: int):
db.execute( db.execute(
""" """
UPDATE items 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 = ? 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() db.commit()
return redirect(url_for("bp.index")) return redirect(url_for("bp.index"))
@@ -677,7 +705,7 @@ def build_bestand() -> list[dict]:
"""Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht.""" """Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht."""
rows = get_db().execute( 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 FROM items
ORDER BY artikel, groesse ORDER BY artikel, groesse
""" """
@@ -690,12 +718,15 @@ def build_bestand() -> list[dict]:
continue continue
item = data.setdefault( item = data.setdefault(
artikel, 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"]: if not item["preis"]:
item["preis"] = float(r["preis"] or 0) item["preis"] = float(r["preis"] or 0)
if not item["bild_url"]: if not item["bild_url"]:
item["bild_url"] = (r["bild_url"] or "").strip() 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) soll = int(r["soll"] or 0)
gezaehlt = int(r["gezaehlt"] or 0) gezaehlt = int(r["gezaehlt"] or 0)
verkaeufe = int(r["verkaeufe"] or 0) verkaeufe = int(r["verkaeufe"] or 0)
@@ -743,7 +774,7 @@ def order():
return jsonify({"error": "Unauthorized"}), 401 return jsonify({"error": "Unauthorized"}), 401
data = request.get_json(silent=True) or request.form 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): if any(not (data.get(k) or "").strip() for k in required):
return jsonify({"error": "Pflichtfelder fehlen."}), 400 return jsonify({"error": "Pflichtfelder fehlen."}), 400
if int(data.get("menge") or 0) <= 0: if int(data.get("menge") or 0) <= 0:
@@ -754,14 +785,15 @@ def order():
payment_method = "bar" payment_method = "bar"
db = get_db() 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) 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') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
""", """,
( (
data.get("name"), data.get("name"),
data.get("handy"), data.get("handy"),
data.get("email"),
data.get("mannschaft"), data.get("mannschaft"),
data.get("artikel"), data.get("artikel"),
data.get("groesse"), data.get("groesse"),
@@ -771,8 +803,9 @@ def order():
payment_method, payment_method,
), ),
) )
order_id = cursor.lastrowid
db.commit() 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") to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
smtp_host = os.environ.get("SMTP_HOST") smtp_host = os.environ.get("SMTP_HOST")
@@ -821,6 +854,69 @@ def order():
threading.Thread(target=_send, daemon=True).start() 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}) return jsonify({"ok": True})
@@ -831,7 +927,7 @@ def orders():
"""Bestellliste in der Verwaltung.""" """Bestellliste in der Verwaltung."""
rows = get_db().execute( 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 FROM orders
ORDER BY id DESC ORDER BY id DESC
LIMIT 500 LIMIT 500
@@ -874,11 +970,12 @@ def complete_order(order_id: int):
""" """
UPDATE items UPDATE items
SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END, SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END,
soll = CASE WHEN soll - ? < 0 THEN 0 ELSE soll - ? END,
verkaeufe = verkaeufe + ?, verkaeufe = verkaeufe + ?,
updated_at = ? updated_at = ?
WHERE artikel = ? AND groesse = ? 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( db.execute(
""" """
@@ -930,6 +1027,80 @@ def cancel_order(order_id: int):
return redirect(url_for("bp.orders")) 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"]) @bp.route("/users/reset/<int:user_id>", methods=["POST"])
@login_required @login_required
def reset_user_password(user_id: int): 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); } .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) { @media (max-width: 720px) {
.actions { display: flex; gap: 6px; flex-wrap: wrap; } .actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn.small { padding: 6px 8px; } .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) Bild hochladen (optional)
<input type="file" name="bild_file" accept="image/*" /> <input type="file" name="bild_file" accept="image/*" />
</label> </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> <label>
Soll Soll
<input type="number" name="soll" min="0" value="{{ item.soll if item else 0 }}" /> <input type="number" name="soll" min="0" value="{{ item.soll if item else 0 }}" />

View File

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