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:
@@ -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
|
|||||||
## Live‑Shop‑Ansicht (index.html)
|
## Live‑Shop‑Ansicht (index.html)
|
||||||
`index.html` lädt den Bestand aus der WaWi‑App:
|
`index.html` lädt den Bestand aus der WaWi‑App:
|
||||||
- API‑Proxy: `/wawi/proxy/bestand`
|
- API‑Proxy: `/wawi/proxy/bestand`
|
||||||
- Konfiguration: `/wawi/config` (lädt PayPal-Konto aus ENV)
|
|
||||||
|
|
||||||
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App unter `/wawi` erreichbar ist.
|
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App 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 Script‑Block gesetzt: `const ORDER_KEY = ""`. Trage dort deinen Key ein (oder leer lassen, wenn kein API-Key erforderlich).
|
Der Key wird in `index.html` im Script‑Block 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 API‑Key für `/wawi/api/bestand` **und** `/wawi/order`
|
- `APP_API_KEY` – gemeinsamer API‑Key für `/wawi/api/bestand` **und** `/wawi/order`
|
||||||
- `COOKIE_SECURE` – `1` (default) setzt Secure‑Cookie, `0` deaktiviert für http
|
- `COOKIE_SECURE` – `1` (default) setzt Secure‑Cookie, `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
|
||||||
|
|
||||||
|
|||||||
104
index.html
104
index.html
@@ -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 => {
|
||||||
|
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-row">
|
||||||
<div class="size-meta"><strong>${fmt(r.groesse)}</strong> · Bestand: ${fmt(r.gezaehlt)}</div>
|
<div class="size-meta">
|
||||||
${(Number(r.gezaehlt) || 0) > 0 ? `<button class="order-btn" data-artikel="${item.artikel}" data-groesse="${fmt(r.groesse)}">Bestellen</button>` : ""}
|
<strong>${fmt(r.groesse)}</strong> · ${stockInfo}
|
||||||
</div>
|
</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("detailSizeList").innerHTML = list;
|
||||||
document.getElementById("detailModal").classList.add("open");
|
document.getElementById("detailModal").classList.add("open");
|
||||||
return;
|
return;
|
||||||
|
|||||||
207
wawi/app.py
207
wawi/app.py
@@ -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 Orders‑Spalten."""
|
"""Sorgt für alle nachträglich eingeführten Orders‑Spalten."""
|
||||||
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 DB‑Zeilen in die Struktur der Live‑Bestand Ansicht."""
|
"""Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand 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
19
wawi/static/style.css
Executable file → Normal 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
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 %}
|
||||||
@@ -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 }}" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user