Compare commits

...

7 Commits

Author SHA1 Message Date
3dcbfecbe4 feat: add info field to articles for shop display
Added optional info text field to articles that can be managed in the backend and is displayed in the shop only when filled. The info appears in both card view and detail modal with an informative style.

- Added info column to items table with migration
- Updated backend edit form with textarea for info text
- Modified API to include info field in bestand response
- Enhanced shop frontend to display info badge when available

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 15:12:12 +01:00
470044c9c9 fix: resolve merge conflicts in app.py
Fixed remaining merge conflict markers in INSERT and SELECT statements for orders table to include email field.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:19:49 +01:00
16f26691af Merge remote changes 2026-02-10 13:15:10 +01:00
e7ca524fda chore: update file permissions and binary files
Minor updates to file permissions and binary assets.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:13:32 +01:00
bc00eeb526 feat: add orders API endpoint with filtering capabilities
Added /api/orders endpoint to retrieve all orders via API with optional filters for status (open/completed/canceled) and payment status (paid/unpaid).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:12:31 +01:00
832aaf2b05 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>
2026-02-09 13:56:16 +01:00
99ecea1de9 feat: add payment tracking for orders
- Add payment method selection (PayPal/Bar) to order form
- Store payment_method and payment_status in database
- Add payment status badges in admin orders view
- Add "mark as paid" functionality for admins
- PayPal account configurable via PAYPAL_ACCOUNT env variable
- Frontend loads PayPal account dynamically from /wawi/config endpoint
- Update email notifications to include payment method

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 08:26:54 +01:00
12 changed files with 542 additions and 31 deletions

0
.DS_Store vendored Normal file → Executable file
View File

0
hellas_bestand Kopie.html Normal file → Executable file
View File

0
hellas_bestand.html Normal file → Executable file
View File

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,45 @@ 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>' : '';
// Info-Text (nur wenn vorhanden)
const infoText = item.info ? `<div class="card-info" style="margin-top: 8px; padding: 8px; background: rgba(243, 213, 42, 0.1); border-left: 3px solid var(--accent); border-radius: 4px; font-size: 12px; line-height: 1.4; color: var(--text);"> ${item.info}</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>
${infoText}
${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 +685,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;
@@ -615,13 +696,30 @@ document.addEventListener("click", (e) => {
media.innerHTML = img ? `<img src="${img}" alt="${artikel}">` : "";
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 => `
const sizesText = `${item.rows.length} Größen`;
const infoText = item.info ? ` · ${item.info}` : '';
document.getElementById("detailSizes").textContent = sizesText + infoText;
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> · 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 class="size-meta">
<strong>${fmt(r.groesse)}</strong> · ${stockInfo}
</div>
`).join("");
${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;

0
logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -166,6 +166,8 @@ def init_db() -> None:
db.commit()
ensure_price_column(db)
ensure_image_column(db)
ensure_sale_column(db)
ensure_info_column(db)
ensure_orders_columns(db)
ensure_payment_columns(db)
ensure_indexes(db)
@@ -190,6 +192,26 @@ 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_info_column(db: sqlite3.Connection) -> None:
"""Fügt info-Spalte für zusätzliche Artikelinformationen hinzu."""
cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "info" for c in cols):
return
db.execute("ALTER TABLE items ADD COLUMN info TEXT")
db.commit()
logger.info("Info-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 +228,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 +417,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 +425,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 +433,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 +469,7 @@ def index():
total=total,
total_bestand=total_bestand,
open_orders=open_orders,
current_year=current_year,
)
@@ -448,6 +485,8 @@ 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
info = (request.form.get("info") or "").strip() or None
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 +495,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, info, 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, info, 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 = ?, info = ? WHERE artikel = ?", (preis, bild_url, sale, info, artikel))
db.commit()
return redirect(url_for("bp.index"))
@@ -485,6 +524,8 @@ 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
info = (request.form.get("info") or "").strip() or None
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 +533,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 = ?, info = ?, 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, info, 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 = ?, info = ? WHERE artikel = ?", (preis, bild_url, sale, info, artikel))
db.commit()
return redirect(url_for("bp.index"))
@@ -673,11 +714,25 @@ def config():
})
@bp.route("/api/orders", methods=["GET"])
@api_key_required
def api_orders():
"""JSON-API für alle Bestellungen (authentifiziert).
Query-Parameter:
- status: "open" (nur offene), "completed" (nur abgeschlossene), "canceled" (nur stornierte)
- payment_status: "paid" (nur bezahlt), "unpaid" (nur unbezahlt)
"""
status = request.args.get("status", "").strip().lower()
payment_status = request.args.get("payment_status", "").strip().lower()
return jsonify(build_orders(status=status, payment_status=payment_status))
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, info, soll, gezaehlt, verkaeufe
FROM items
ORDER BY artikel, groesse
"""
@@ -690,12 +745,18 @@ 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, "info": None, "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
# Info-Text (nur wenn vorhanden)
if r["info"] and not item["info"]:
item["info"] = (r["info"] or "").strip()
soll = int(r["soll"] or 0)
gezaehlt = int(r["gezaehlt"] or 0)
verkaeufe = int(r["verkaeufe"] or 0)
@@ -728,6 +789,91 @@ def build_bestand() -> list[dict]:
return result
def build_orders(status: str = "", payment_status: str = "") -> list[dict]:
"""Gibt alle Bestellungen als JSON-kompatible Liste zurück.
Args:
status: Filter nach Status ("open", "completed", "canceled")
payment_status: Filter nach Zahlungsstatus ("paid", "unpaid")
"""
where_clauses = []
params = []
# Status-Filter
if status == "open":
where_clauses.append("done = 0 AND canceled = 0")
elif status == "completed":
where_clauses.append("done = 1 AND canceled = 0")
elif status == "canceled":
where_clauses.append("canceled = 1")
# Zahlungsstatus-Filter
if payment_status == "paid":
where_clauses.append("payment_status = 'paid'")
elif payment_status == "unpaid":
where_clauses.append("payment_status = 'unpaid'")
where_sql = ""
if where_clauses:
where_sql = "WHERE " + " AND ".join(where_clauses)
sql = f"""
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
{where_sql}
ORDER BY id DESC
"""
rows = get_db().execute(sql, params).fetchall()
result = []
for r in rows:
result.append({
"id": r["id"],
"name": r["name"],
"handy": r["handy"],
"email": r["email"],
"mannschaft": r["mannschaft"],
"artikel": r["artikel"],
"groesse": r["groesse"],
"menge": r["menge"],
"notiz": r["notiz"],
"created_at": r["created_at"],
"done": bool(r["done"]),
"completed_by": r["completed_by"],
"completed_at": r["completed_at"],
"canceled": bool(r["canceled"]),
"canceled_by": r["canceled_by"],
"canceled_at": r["canceled_at"],
"payment_method": r["payment_method"],
"payment_status": r["payment_status"],
"paid_at": r["paid_at"],
"paid_by": r["paid_by"],
})
return result
@bp.route("/order", methods=["POST"])
@csrf.exempt # JSON API ohne CSRF-Schutz (nutzt API-Key stattdessen)
def order():
@@ -743,7 +889,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 +900,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 +918,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 +969,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 +1042,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 +1085,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 +1142,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):

0
wawi/import_from_html.py Normal file → Executable file
View File

0
wawi/static/logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

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

0
wawi/templates/base.html Normal file → Executable file
View File

View File

@@ -25,6 +25,14 @@
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 style="grid-column: 1 / -1;">
Info-Text für Shop (optional)
<textarea name="info" rows="3" placeholder="z.B. Lieferzeit 2-3 Wochen, limitierte Auflage, etc.">{{ item.info if item and item.info else '' }}</textarea>
</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>