From f604ab7c4ff30a45a9588ca8e2f4382eb94e62bc Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 18:20:23 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20QR-Kontingente=20f=C3=BCr=20Partner?= =?UTF-8?q?=20=E2=80=94=20Bestellung,=20=C3=9Cbergabe,=20R=C3=BCckverfolgu?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code ist einzeln rückverfolgbar von Scan bis Registrierung. Backend: - partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I), users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by) - /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect /?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an - Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten Partner-Code gehört (Manipulationsschutz) - Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen, Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit Kurz-URL + laufender Nummer), Code-Besitzer zuordnen - Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer Frontend: - Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer) - Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download - boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung, Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed. --- VERSION | 2 +- backend/database.py | 46 +++++ backend/main.py | 31 +++ backend/requirements.txt | 1 + backend/routes/auth.py | 11 ++ backend/routes/partner.py | 223 +++++++++++++++++++++- backend/static/index.html | 24 +-- backend/static/js/api.js | 3 +- backend/static/js/app.js | 5 +- backend/static/js/boot.js | 3 + backend/static/js/pages/admin.js | 125 +++++++++++- backend/static/js/pages/partner-profil.js | 32 ++++ backend/static/js/pages/settings.js | 5 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 129 +++++++++++++ 16 files changed, 621 insertions(+), 23 deletions(-) create mode 100644 tests/test_partner_qr.py diff --git a/VERSION b/VERSION index 832d4ca..94ad64d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1256 \ No newline at end of file +1257 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 1ddfc8e..1169b5d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,6 +623,10 @@ def _migrate(conn_factory): ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "founder_number", "INTEGER"), ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), + # QR-Rückverfolgung: über welchen physischen QR-Code (Sticker/Flyer) kam die Registrierung + ("users", "referred_qr", "TEXT"), + # Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen) + ("partner_codes", "owner_user_id", "INTEGER"), # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), @@ -1685,6 +1689,48 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_profiles: {e}") + # QR-Kontingente für Partner (gedruckte Sticker/Flyer mit Rückverfolgung) + # Jeder physische QR-Code hat einen eigenen Token → Scan- und + # Registrierungs-Tracking pro Einzelcode und pro Kontingent. + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS partner_qr_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + partner_code_id INTEGER NOT NULL REFERENCES partner_codes(id) ON DELETE CASCADE, + label TEXT NOT NULL, + quantity INTEGER NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS partner_qr_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES partner_qr_batches(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + seq INTEGER NOT NULL, + scans INTEGER NOT NULL DEFAULT 0, + first_scan_at TEXT, + last_scan_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pqr_token ON partner_qr_codes(token); + CREATE INDEX IF NOT EXISTS idx_pqr_batch ON partner_qr_codes(batch_id); + """) + logger.info("Migration: partner_qr Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration partner_qr: {e}") + try: + # Backfill: Partner, die sich mit ihrem eigenen Code registriert haben, + # als Code-Besitzer verknüpfen (für Self-Service-Zugriff auf QR-Stats). + # Eigener try-Block: owner_user_id kommt auf frischen DBs erst im 2nd pass. + conn.execute(""" + UPDATE partner_codes SET owner_user_id = ( + SELECT u.id FROM users u + WHERE u.referred_by = -partner_codes.id AND u.is_partner = 1 + LIMIT 1 + ) WHERE owner_user_id IS NULL + """) + except Exception as e: + logger.debug(f"Backfill partner_codes.owner_user_id übersprungen: {e}") + # Outreach-Log (Admin-E-Mail-Versand) try: conn.executescript(""" diff --git a/backend/main.py b/backend/main.py index b473156..309e184 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2156,6 +2156,37 @@ setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000); return HTMLResponse(content=html, headers={"Cache-Control": "no-store"}) +# /q/{token} — Partner-QR-Scan: zählen + auf Registrierung mit Code umleiten +# ------------------------------------------------------------------ +@app.get("/q/{token}") +async def partner_qr_scan(token: str): + from fastapi.responses import RedirectResponse as _Redirect + from database import db as _db + token = token.strip() + with _db() as conn: + row = conn.execute( + """SELECT q.token, pc.code + FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE q.token = ?""", + (token,) + ).fetchone() + if not row: + return _Redirect("/", status_code=302) + conn.execute( + """UPDATE partner_qr_codes + SET scans = scans + 1, + first_scan_at = COALESCE(first_scan_at, datetime('now')), + last_scan_at = datetime('now') + WHERE token = ?""", + (token,) + ) + # ?ref= nutzt den bestehenden Partner-Code-Flow, ?qr= ergänzt die Einzelcode-Zuordnung + return _Redirect(f"/?ref={row['code']}&qr={row['token']}", status_code=302) + + +# ------------------------------------------------------------------ # /partner — Influencer-Landingpage # ------------------------------------------------------------------ @app.get("/partner") diff --git a/backend/requirements.txt b/backend/requirements.txt index d45b6f8..6508529 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,5 +15,6 @@ apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 +segno==1.6.6 python-dateutil>=2.9 brotli-asgi==1.4.0 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 18b092b..718d6cd 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -153,6 +153,7 @@ class RegisterRequest(BaseModel): password: str = Field(..., min_length=8, max_length=200) name: str = Field(..., min_length=2, max_length=40) ref_code: Optional[str] = Field(None, max_length=50) + qr_token: Optional[str] = Field(None, max_length=20) # physischer Partner-QR (Sticker/Flyer) def _gen_referral_code() -> str: @@ -227,6 +228,16 @@ async def register(data: RegisterRequest, response: Response, request: Request): if redeemed: updates = {"referred_by": -partner["id"]} + # QR-Rückverfolgung: Token muss zu einem Kontingent DIESES Codes gehören + if data.qr_token: + qr = conn.execute( + """SELECT q.token FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + WHERE q.token=? AND b.partner_code_id=?""", + (data.qr_token.strip(), partner["id"]) + ).fetchone() + if qr: + updates["referred_qr"] = qr["token"] if partner["grants_founder"]: total_founders = conn.execute( "SELECT COUNT(*) FROM users WHERE is_founder=1" diff --git a/backend/routes/partner.py b/backend/routes/partner.py index dff79c9..ec0ebc5 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -37,10 +37,12 @@ def list_partner_codes(user=Depends(require_admin)): with db() as conn: rows = conn.execute( """SELECT pc.id, pc.code, pc.label, pc.grants_founder, - pc.max_uses, pc.uses, pc.created_at, - u.name AS created_by_name + pc.max_uses, pc.uses, pc.created_at, pc.owner_user_id, + u.name AS created_by_name, + o.name AS owner_name FROM partner_codes pc LEFT JOIN users u ON u.id = pc.created_by + LEFT JOIN users o ON o.id = pc.owner_user_id ORDER BY pc.created_at DESC""" ).fetchall() return [dict(r) for r in rows] @@ -546,3 +548,220 @@ def review_partner_profile(user_id: int, data: PartnerProfileReview, user=Depend ) profile = _pp_get_or_empty(conn, user_id) return {"profile": profile} + + +# ------------------------------------------------------------------ +# QR-Kontingente — gedruckte Sticker/Flyer mit Scan- und +# Registrierungs-Rückverfolgung pro Einzelcode und Kontingent. +# Bestellung: Admin legt Kontingent für einen Partner-Code an. +# Übergabe: PDF-Download (Admin + Partner im eigenen Profil). +# ------------------------------------------------------------------ + +_QR_MAX_QUANTITY = 500 +# Ohne verwechselbare Zeichen (0/O, 1/l/I) — Tokens landen gedruckt auf Stickern +_QR_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" +_QR_BASE_URL = os.getenv("APP_URL", "https://banyaro.app") + + +def _qr_new_token(conn) -> str: + import secrets + for _ in range(20): + token = "".join(secrets.choice(_QR_ALPHABET) for _ in range(8)) + if not conn.execute( + "SELECT 1 FROM partner_qr_codes WHERE token=?", (token,) + ).fetchone(): + return token + raise HTTPException(500, "Token-Generierung fehlgeschlagen.") + + +def _qr_batch_stats(conn, batch_id: int) -> dict: + row = conn.execute( + """SELECT COUNT(*) AS codes, COALESCE(SUM(q.scans),0) AS scans, + (SELECT COUNT(*) FROM users u + JOIN partner_qr_codes q2 ON q2.token = u.referred_qr + WHERE q2.batch_id = ?) AS registrations + FROM partner_qr_codes q WHERE q.batch_id = ?""", + (batch_id, batch_id) + ).fetchone() + return dict(row) + + +def _qr_list_batches(conn, where_sql: str, params: tuple) -> list: + rows = conn.execute( + f"""SELECT b.id, b.label, b.quantity, b.created_at, + pc.code, pc.label AS code_label, pc.id AS partner_code_id + FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + {where_sql} + ORDER BY b.created_at DESC""", + params + ).fetchall() + result = [] + for r in rows: + d = dict(r) + d.update(_qr_batch_stats(conn, r["id"])) + result.append(d) + return result + + +def _qr_batch_pdf(conn, batch_id: int) -> bytes: + """Druckfertiges A4-PDF: 3×4 QR-Codes pro Seite mit Kurz-URL + laufender Nummer.""" + import io as _io + import segno + from fpdf import FPDF + + batch = conn.execute( + """SELECT b.id, b.label, b.quantity, pc.code, pc.label AS code_label + FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE b.id=?""", + (batch_id,) + ).fetchone() + if not batch: + raise HTTPException(404, "Kontingent nicht gefunden.") + codes = conn.execute( + "SELECT token, seq FROM partner_qr_codes WHERE batch_id=? ORDER BY seq", + (batch_id,) + ).fetchall() + + pdf = FPDF(format="A4") + pdf.set_auto_page_break(False) + pdf.set_title(f"Ban Yaro QR-Kontingent — {batch['label']}") + + COLS, ROWS = 3, 4 + CELL_W, CELL_H = 60, 64 # mm — Zelle inkl. Beschriftung + MARGIN_X = (210 - COLS * CELL_W) / 2 + MARGIN_Y = 18 + QR_SIZE = 42 # mm + + def _latin1(s: str) -> str: + return s.encode("latin-1", "replace").decode("latin-1") + + for i, c in enumerate(codes): + pos = i % (COLS * ROWS) + if pos == 0: + pdf.add_page() + pdf.set_font("Helvetica", "B", 11) + pdf.set_text_color(60) + pdf.cell(0, 6, _latin1(f"Ban Yaro — {batch['code_label']} · Kontingent: {batch['label']} ({batch['quantity']} Stk.)"), + align="C", new_x="LMARGIN", new_y="NEXT") + col, row_ = pos % COLS, pos // COLS + x = MARGIN_X + col * CELL_W + y = MARGIN_Y + 10 + row_ * CELL_H + + url = f"{_QR_BASE_URL}/q/{c['token']}" + buf = _io.BytesIO() + segno.make(url, error="m").save(buf, kind="png", scale=8, border=2) + buf.seek(0) + pdf.image(buf, x=x + (CELL_W - QR_SIZE) / 2, y=y, w=QR_SIZE, h=QR_SIZE) + + pdf.set_xy(x, y + QR_SIZE + 1) + pdf.set_font("Helvetica", "", 8) + pdf.set_text_color(90) + pdf.cell(CELL_W, 4, _latin1(f"banyaro.app/q/{c['token']}"), align="C", new_x="LEFT", new_y="NEXT") + pdf.set_x(x) + pdf.set_font("Helvetica", "B", 8) + pdf.cell(CELL_W, 4, f"#{c['seq']}", align="C") + + return bytes(pdf.output()) + + +class QrBatchCreate(BaseModel): + label: str = Field(..., min_length=1, max_length=100) + quantity: int = Field(..., ge=1, le=_QR_MAX_QUANTITY) + + +@router.post("/admin/partner/codes/{code_id}/qr-batches", status_code=201) +def create_qr_batch(code_id: int, data: QrBatchCreate, user=Depends(require_admin)): + """Bestellung: neues QR-Kontingent für einen Partner-Code anlegen.""" + with db() as conn: + code = conn.execute( + "SELECT id FROM partner_codes WHERE id=?", (code_id,) + ).fetchone() + if not code: + raise HTTPException(404, "Partner-Code nicht gefunden.") + conn.execute( + "INSERT INTO partner_qr_batches (partner_code_id, label, quantity, created_by) VALUES (?,?,?,?)", + (code_id, data.label.strip(), data.quantity, user["id"]) + ) + batch_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + for seq in range(1, data.quantity + 1): + conn.execute( + "INSERT INTO partner_qr_codes (batch_id, token, seq) VALUES (?,?,?)", + (batch_id, _qr_new_token(conn), seq) + ) + batches = _qr_list_batches(conn, "WHERE b.id=?", (batch_id,)) + return batches[0] + + +@router.get("/admin/partner/qr-batches") +def list_qr_batches(user=Depends(require_admin)): + """Alle QR-Kontingente mit Stats (Scans, Registrierungen).""" + with db() as conn: + return _qr_list_batches(conn, "", ()) + + +@router.delete("/admin/partner/qr-batches/{batch_id}", status_code=204) +def delete_qr_batch(batch_id: int, user=Depends(require_admin)): + """Kontingent löschen (z. B. Fehlbestellung) — Codes via CASCADE mit weg.""" + with db() as conn: + if not conn.execute( + "SELECT id FROM partner_qr_batches WHERE id=?", (batch_id,) + ).fetchone(): + raise HTTPException(404, "Kontingent nicht gefunden.") + conn.execute("DELETE FROM partner_qr_batches WHERE id=?", (batch_id,)) + return None + + +@router.get("/admin/partner/qr-batches/{batch_id}/pdf") +def qr_batch_pdf_admin(batch_id: int, user=Depends(require_admin)): + from fastapi.responses import Response + with db() as conn: + pdf = _qr_batch_pdf(conn, batch_id) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) + + +@router.get("/partner/my-qr") +def my_qr_batches(user=Depends(require_partner)): + """Übergabe/Self-Service: eigene Kontingente mit Stats (Code-Besitzer).""" + with db() as conn: + return _qr_list_batches( + conn, "WHERE pc.owner_user_id = ?", (user["id"],) + ) + + +@router.get("/partner/my-qr/{batch_id}/pdf") +def qr_batch_pdf_partner(batch_id: int, user=Depends(require_partner)): + from fastapi.responses import Response + with db() as conn: + own = conn.execute( + """SELECT b.id FROM partner_qr_batches b + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE b.id=? AND pc.owner_user_id=?""", + (batch_id, user["id"]) + ).fetchone() + if not own and user.get("rolle") != "admin": + raise HTTPException(403, "Kein Zugriff auf dieses Kontingent.") + pdf = _qr_batch_pdf(conn, batch_id) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="banyaro-qr-{batch_id}.pdf"'}) + + +class CodeOwnerSet(BaseModel): + user_id: int + + +@router.post("/admin/partner/codes/{code_id}/owner") +def set_code_owner(code_id: int, data: CodeOwnerSet, user=Depends(require_admin)): + """Partner-Code einem User zuordnen (für Self-Service-QR-Zugriff).""" + with db() as conn: + if not conn.execute("SELECT id FROM partner_codes WHERE id=?", (code_id,)).fetchone(): + raise HTTPException(404, "Partner-Code nicht gefunden.") + if not conn.execute("SELECT id FROM users WHERE id=?", (data.user_id,)).fetchone(): + raise HTTPException(404, "User nicht gefunden.") + conn.execute( + "UPDATE partner_codes SET owner_user_id=? WHERE id=?", + (data.user_id, code_id) + ) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index ac806cb..1acae3c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 7fd420e..b5022e2 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -114,9 +114,10 @@ const API = (() => { login(email, password) { return post('/auth/login', { email, password }); }, - register(email, password, name, ref_code) { + register(email, password, name, ref_code, qr_token) { const body = { email, password, name }; if (ref_code) body.ref_code = ref_code; + if (qr_token) body.qr_token = qr_token; // Partner-QR (Sticker/Flyer) — Rückverfolgung return post('/auth/register', body); }, logout() { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 77b177d..5ea91f4 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1256'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1257'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; @@ -1140,10 +1140,13 @@ const App = (() => { // überlebt App-Schließen, sodass die Zuordnung auch bei späterer Registrierung klappt) const urlParams = new URLSearchParams(window.location.search); const refCode = urlParams.get('ref'); + const qrToken = urlParams.get('qr'); if (refCode) { try { localStorage.setItem('by_ref_code', refCode.toUpperCase()); localStorage.setItem('by_ref_code_ts', String(Date.now())); + // Partner-QR-Token (Sticker/Flyer) für Einzelcode-Rückverfolgung mitspeichern + if (qrToken) localStorage.setItem('by_qr_token', qrToken); } catch {} // URL bereinigen ohne Reload history.replaceState({}, '', window.location.pathname + window.location.hash); diff --git a/backend/static/js/boot.js b/backend/static/js/boot.js index 09e1624..8165c64 100644 --- a/backend/static/js/boot.js +++ b/backend/static/js/boot.js @@ -17,6 +17,9 @@ localStorage.setItem('by_ref_code', rc.toUpperCase()); localStorage.setItem('by_ref_code_ts', String(Date.now())); } + // Partner-QR-Token (?qr= aus /q/{token}-Redirect) — Rückverfolgung pro Sticker/Flyer + var qt = new URLSearchParams(location.search).get('qr'); + if (qt) localStorage.setItem('by_qr_token', qt); // Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot // die URL-Query strippt). Wird in ui.js Map.create ausgewertet. var vm = new URLSearchParams(location.search).get('vectormap'); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 21d2f92..3f15c6b 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2290,8 +2290,9 @@ window.Page_admin = (() => { // TAB: AUDIT-LOG // ------------------------------------------------------------------ async function _renderPartner(el) { - const codes = (await API.get('/admin/partner/codes')) || []; - const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || []; + const codes = (await API.get('/admin/partner/codes')) || []; + const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || []; + const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || []; el.innerHTML = `
@@ -2364,7 +2365,14 @@ window.Page_admin = (() => { ${c.code} - ${c.label} + + ${c.label} +
+ ${c.owner_name + ? `👤 ${UI.escape(c.owner_name)}` + : ``} +
+ ${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} @@ -2385,6 +2393,69 @@ window.Page_admin = (() => {
+ +
+

QR-Kontingente

+

+ Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln + rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ ${qrBatches.length === 0 + ? `

Noch keine Kontingente bestellt.

` + : ` + + + + + + + + + + + + ${qrBatches.map(b => ` + + + + + + + + `).join('')} + +
CodeKontingentStk.ScansRegistr.
${UI.escape(b.code)}${UI.escape(b.label)}
${(b.created_at || '').slice(0, 10)}
${b.quantity}${b.scans}${b.registrations} + + ${UI.icon('file-pdf')} PDF + + +
`} +
+

@@ -2483,6 +2554,54 @@ window.Page_admin = (() => {

`; + // Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner) + el.querySelectorAll('.adm-code-owner').forEach(btn => { + btn.addEventListener('click', async () => { + const q = window.prompt('Benutzername des Partners (exakt):'); + if (!q) return; + try { + const hits = await API.get(`/admin/users/search?q=${encodeURIComponent(q.trim())}`); + const hit = (hits || []).find(u => u.name.toLowerCase() === q.trim().toLowerCase()) || (hits || [])[0]; + if (!hit) { UI.toast.warning('Kein User gefunden.'); return; } + await API.post(`/admin/partner/codes/${btn.dataset.id}/owner`, { user_id: hit.id }); + UI.toast.success(`Code gehört jetzt ${hit.name} — er sieht seine QR-Kontingente im Partner-Profil.`); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + + // QR-Kontingent anlegen + el.querySelector('#adm-qr-create')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + await UI.asyncButton(btn, async () => { + const b = await API.post(`/admin/partner/codes/${fd.code_id}/qr-batches`, { + label: fd.label, + quantity: parseInt(fd.quantity), + }); + UI.toast.success(`Kontingent "${b.label}" mit ${b.quantity} QR-Codes erstellt.`); + await _renderPartner(el); + }); + }); + + // QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal) + el.querySelectorAll('.adm-qr-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (btn.dataset.armed !== '1') { + btn.dataset.armed = '1'; + btn.textContent = 'Wirklich löschen?'; + setTimeout(() => { btn.dataset.armed = '0'; btn.innerHTML = UI.icon('trash'); }, 3000); + return; + } + try { + await API.del(`/admin/partner/qr-batches/${btn.dataset.id}`); + UI.toast.success(`Kontingent "${btn.dataset.label}" gelöscht.`); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList) el.querySelectorAll('.adm-pp-preview').forEach(btn => { btn.addEventListener('click', () => { diff --git a/backend/static/js/pages/partner-profil.js b/backend/static/js/pages/partner-profil.js index aafd6a5..0402c36 100644 --- a/backend/static/js/pages/partner-profil.js +++ b/backend/static/js/pages/partner-profil.js @@ -36,6 +36,8 @@ window.Page_partner_profil = (() => { `; } + let _qrBatches = []; + async function _load() { const el = _container.querySelector('#pp-content'); try { @@ -43,6 +45,7 @@ window.Page_partner_profil = (() => { _profile = d.profile || {}; _profile._storage_mb = d.storage_mb || 0; _profile._storage_limit_mb = d.storage_limit_mb || 200; + _qrBatches = (await API.get('/partner/my-qr').catch(() => [])) || []; el.innerHTML = _renderEditor(); _bindEvents(el); } catch (e) { @@ -178,6 +181,35 @@ window.Page_partner_profil = (() => { + ${_qrBatches.length ? ` + +
+
Meine QR-Codes
+

+ Deine gedruckten QR-Codes (Sticker, Flyer). Jeder Scan und jede Registrierung + darüber wird gezählt — so siehst du, was wo funktioniert. +

+ ${_qrBatches.map(b => ` +
+
+
${UI.escape(b.label)}
+
${b.quantity} Codes · ${(b.created_at || '').slice(0, 10)}
+
+
+
${b.scans}
+
Scans
+
+
+
${b.registrations}
+
Registr.
+
+ + ${UI.icon('file-pdf')} PDF + +
`).join('')} +
` : ''} +