Compare commits
5 Commits
aa0de2fe4a
...
16f26691af
| Author | SHA1 | Date | |
|---|---|---|---|
| 16f26691af | |||
| e7ca524fda | |||
| bc00eeb526 | |||
| 832aaf2b05 | |||
| 99ecea1de9 |
0
hellas_bestand Kopie.html
Normal file → Executable file
0
hellas_bestand Kopie.html
Normal file → Executable file
0
hellas_bestand.html
Normal file → Executable file
0
hellas_bestand.html
Normal file → Executable file
104
index.html
104
index.html
@@ -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 => `
|
||||
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
0
logo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
309
wawi/app.py
309
wawi/app.py
@@ -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 Orders‑Spalten."""
|
||||
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"))
|
||||
|
||||
@@ -673,11 +701,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 DB‑Zeilen in die Struktur der Live‑Bestand 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 +732,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)
|
||||
@@ -728,6 +773,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 +873,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 +884,20 @@ def order():
|
||||
payment_method = "bar"
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
cursor = db.execute(
|
||||
"""
|
||||
<<<<<<< HEAD
|
||||
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')
|
||||
=======
|
||||
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')
|
||||
>>>>>>> origin/main
|
||||
""",
|
||||
(
|
||||
data.get("name"),
|
||||
data.get("handy"),
|
||||
data.get("email"),
|
||||
data.get("mannschaft"),
|
||||
data.get("artikel"),
|
||||
data.get("groesse"),
|
||||
@@ -771,8 +907,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 +958,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 +1031,11 @@ def orders():
|
||||
"""Bestellliste in der Verwaltung."""
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
<<<<<<< HEAD
|
||||
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
|
||||
=======
|
||||
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
|
||||
>>>>>>> origin/main
|
||||
FROM orders
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
@@ -874,11 +1078,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 +1135,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
0
wawi/import_from_html.py
Normal file → Executable file
0
wawi/static/logo.png
Normal file → Executable file
0
wawi/static/logo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
116
wawi/templates/abrechnung.html
Executable file
116
wawi/templates/abrechnung.html
Executable 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
0
wawi/templates/base.html
Normal file → Executable 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 }}" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user