diff --git a/backend/database.py b/backend/database.py index 3d9757f..af5298b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2265,6 +2265,28 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration behavior_log: {e}") + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS litter_waitlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + litter_id INTEGER NOT NULL REFERENCES litters(id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT, + telefon TEXT, + nachricht TEXT, + wunsch_geschlecht TEXT DEFAULT 'egal', + wunsch_farbe TEXT, + prioritaet INTEGER DEFAULT 0, + status TEXT DEFAULT 'anfrage', + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_waitlist_litter ON litter_waitlist(litter_id, prioritaet)") + logger.info("Migration: litter_waitlist bereit.") + except Exception as e: + logger.warning(f"Migration litter_waitlist: {e}") + # route_dogs: bestehende Routen allen Hunden des Users zuweisen try: existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] diff --git a/backend/main.py b/backend/main.py index f819647..4485c8d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -404,7 +404,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "890" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "891" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/litters.py b/backend/routes/litters.py index ddc810c..c9dde95 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -650,3 +650,98 @@ async def generate_contract( """ return HTMLResponse(content=html) + + +# ------------------------------------------------------------------ +# Warteliste +# ------------------------------------------------------------------ +class WaitlistEntry(BaseModel): + name: str + email: Optional[str] = None + telefon: Optional[str] = None + nachricht: Optional[str] = None + wunsch_geschlecht: str = "egal" + wunsch_farbe: Optional[str] = None + prioritaet: int = 0 + status: str = "anfrage" + notiz: Optional[str] = None + + +class WaitlistUpdate(BaseModel): + name: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + nachricht: Optional[str] = None + wunsch_geschlecht: Optional[str] = None + wunsch_farbe: Optional[str] = None + prioritaet: Optional[int] = None + status: Optional[str] = None + notiz: Optional[str] = None + + +@router.get("/litters/{litter_id}/waitlist") +async def get_waitlist(litter_id: int, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + rows = conn.execute( + "SELECT * FROM litter_waitlist WHERE litter_id=? ORDER BY prioritaet, created_at", + (litter_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/litters/{litter_id}/waitlist", status_code=201) +async def add_waitlist_entry(litter_id: int, body: WaitlistEntry, user=Depends(_require_breeder)): + with db() as conn: + _check_litter_owner(litter_id, user, conn) + cur = conn.execute( + """INSERT INTO litter_waitlist + (litter_id, name, email, telefon, nachricht, wunsch_geschlecht, wunsch_farbe, + prioritaet, status, notiz) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + (litter_id, body.name, body.email, body.telefon, body.nachricht, + body.wunsch_geschlecht, body.wunsch_farbe, body.prioritaet, body.status, body.notiz) + ) + row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.put("/litters/waitlist/{entry_id}") +async def update_waitlist_entry(entry_id: int, body: WaitlistUpdate, user=Depends(_require_breeder)): + with db() as conn: + entry = conn.execute( + """SELECT w.*, bp.user_id AS owner_user_id FROM litter_waitlist w + JOIN litters l ON l.id = w.litter_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE w.id=?""", + (entry_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if not fields: + return dict(entry) + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE litter_waitlist SET {sets} WHERE id=?", (*fields.values(), entry_id)) + row = conn.execute("SELECT * FROM litter_waitlist WHERE id=?", (entry_id,)).fetchone() + return dict(row) + + +@router.delete("/litters/waitlist/{entry_id}", status_code=204) +async def delete_waitlist_entry(entry_id: int, user=Depends(_require_breeder)): + with db() as conn: + entry = conn.execute( + """SELECT w.id, bp.user_id AS owner_user_id FROM litter_waitlist w + JOIN litters l ON l.id = w.litter_id + JOIN breeder_profiles bp ON bp.id = l.breeder_id + WHERE w.id=?""", + (entry_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + if user["rolle"] != "admin" and entry["owner_user_id"] != user["id"]: + raise HTTPException(403, "Kein Zugriff.") + conn.execute("DELETE FROM litter_waitlist WHERE id=?", (entry_id,)) diff --git a/backend/static/index.html b/backend/static/index.html index 39016d0..49a2065 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 7a341a1..bb24d55 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -709,6 +709,11 @@ const API = (() => { addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); }, updatePuppy(id, data) { return put(`/litters/puppies/${id}`, data); }, addWeight(id, data) { return post(`/litters/puppies/${id}/weight`, data); }, + // Warteliste + waitlist(id) { return get(`/litters/${id}/waitlist`); }, + addWaitlist(id, data) { return post(`/litters/${id}/waitlist`, data); }, + updateWaitlist(entryId, data) { return put(`/litters/waitlist/${entryId}`, data); }, + removeWaitlist(entryId) { return del(`/litters/waitlist/${entryId}`); }, // Öffentlich public(params) { return get('/litters?' + new URLSearchParams(params || {}).toString()); }, detail(id) { return get(`/litters/${id}`); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d701647..b84cbc0 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 = '890'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '891'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/litters.js b/backend/static/js/pages/litters.js index 88a89f3..9675f67 100644 --- a/backend/static/js/pages/litters.js +++ b/backend/static/js/pages/litters.js @@ -206,6 +206,14 @@ window.Page_litters = (() => { }); }); + el.querySelectorAll('.litters-waitlist-btn').forEach(btn => { + btn.addEventListener('click', () => _toggleWaitlist(parseInt(btn.dataset.id))); + }); + + el.querySelectorAll('.litters-add-waitlist-btn').forEach(btn => { + btn.addEventListener('click', () => _showWaitlistForm(parseInt(btn.dataset.id), null)); + }); + // Aufgeklappten Wurf wiederherstellen if (_openId) _togglePuppies(_openId, true); } @@ -248,6 +256,10 @@ window.Page_litters = (() => { title="Welpen anzeigen"> ${UI.icon('caret-down')} Welpen + + `; } @@ -561,6 +582,180 @@ window.Page_litters = (() => { } } + // ---------------------------------------------------------- + // Warteliste + // ---------------------------------------------------------- + const _WL_STATUS = { + anfrage: { label: 'Anfrage', color: '#6b7280' }, + vorgemerkt: { label: 'Vorgemerkt', color: '#f59e0b' }, + bestaetigt: { label: 'Bestätigt', color: '#3b82f6' }, + abgegeben: { label: 'Abgegeben', color: '#16a34a' }, + abgesagt: { label: 'Abgesagt', color: '#dc2626' }, + }; + + function _wlStatusBadge(status) { + const s = _WL_STATUS[status] || _WL_STATUS.anfrage; + return `${s.label}`; + } + + async function _toggleWaitlist(litterId) { + const wrap = document.getElementById(`waitlist-wrap-${litterId}`); + if (!wrap) return; + const isOpen = wrap.style.display !== 'none'; + if (isOpen) { wrap.style.display = 'none'; return; } + wrap.style.display = ''; + await _loadWaitlist(litterId); + } + + async function _loadWaitlist(litterId) { + const inner = document.getElementById(`waitlist-inner-${litterId}`); + if (!inner) return; + try { + const entries = await API.litters.waitlist(litterId); + _renderWaitlist(inner, litterId, entries); + } catch (err) { + inner.innerHTML = `

${_esc(err.message || 'Fehler.')}

`; + } + } + + function _renderWaitlist(container, litterId, entries) { + if (!entries.length) { + container.innerHTML = `

Noch keine Interessenten eingetragen.

`; + return; + } + container.innerHTML = ` +
+ ${entries.map((e, i) => ` +
+
${i + 1}
+
+
+ ${_esc(e.name)} + ${_wlStatusBadge(e.status)} + ${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}` : ''} + ${e.wunsch_farbe ? `${_esc(e.wunsch_farbe)}` : ''} +
+
+ ${e.email ? `${UI.icon('envelope')} ${_esc(e.email)}` : ''} + ${e.telefon ? `${UI.icon('phone')} ${_esc(e.telefon)}` : ''} + ${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'} +
+ ${e.nachricht ? `
"${_esc(e.nachricht)}"
` : ''} + ${e.notiz ? `
${UI.icon('note-pencil')} ${_esc(e.notiz)}
` : ''} +
+
+ + +
+
`).join('')} +
`; + + container.querySelectorAll('.wl-edit-btn').forEach(btn => { + btn.addEventListener('click', () => { + const entry = entries.find(e => e.id === parseInt(btn.dataset.entryId)); + if (entry) _showWaitlistForm(litterId, entry); + }); + }); + + container.querySelectorAll('.wl-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Interessenten aus der Warteliste entfernen?')) return; + try { + await API.litters.removeWaitlist(parseInt(btn.dataset.entryId)); + await _loadWaitlist(litterId); + } catch (err) { UI.toast.error(err.message || 'Fehler.'); } + }); + }); + } + + function _showWaitlistForm(litterId, entry) { + const isEdit = !!entry; + const v = entry || {}; + UI.modal.open({ + title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen', + body: ` +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
`, + footer: ` + + `, + }); + + document.getElementById('wl-form').addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const data = { + name: fd.get('name')?.trim(), + email: fd.get('email')?.trim() || null, + telefon: fd.get('telefon')?.trim() || null, + nachricht: fd.get('nachricht')?.trim() || null, + wunsch_geschlecht: fd.get('wunsch_geschlecht'), + wunsch_farbe: fd.get('wunsch_farbe')?.trim() || null, + prioritaet: parseInt(fd.get('prioritaet')) || 0, + status: fd.get('status'), + notiz: fd.get('notiz')?.trim() || null, + }; + try { + if (isEdit) { + await API.litters.updateWaitlist(entry.id, data); + } else { + await API.litters.addWaitlist(litterId, data); + } + UI.modal.close(); + await _loadWaitlist(litterId); + UI.toast.success(isEdit ? 'Gespeichert.' : 'Interessent eingetragen.'); + } catch (err) { UI.toast.error(err.message || 'Fehler.'); } + }); + } + // ---------------------------------------------------------- // Wurf-Formular (neu / bearbeiten) // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 250e7b2..4b9693e 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v890'; +const CACHE_VERSION = 'by-v891'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache