From 40de0f38aa4731410fd263821c218ab9600ad9c7 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 21:02:49 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Tierarzt-Bewertungen=20=E2=80=94=20S?= =?UTF-8?q?terne-Rating=20pro=20Praxis=20mit=20Detail-Modal=20(SW=20by-v70?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 75 +++++++++ backend/routes/tieraerzte.py | 114 +++++++++++++ backend/static/index.html | 12 +- backend/static/js/api.js | 3 + backend/static/js/pages/health.js | 262 +++++++++++++++++++++++++++++- 5 files changed, 461 insertions(+), 5 deletions(-) diff --git a/backend/database.py b/backend/database.py index a98cda8..5e38a96 100644 --- a/backend/database.py +++ b/backend/database.py @@ -573,6 +573,9 @@ def _migrate(conn_factory): ("users", "password_reset_expires", "TEXT"), # Fell-Typ für personalisierte Wetter-Hinweise ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt + # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz + ("tieraerzte", "avg_rating", "REAL DEFAULT 0"), + ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1983,3 +1986,75 @@ def _migrate(conn_factory): ); CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); """) + + # ---- Tierarzt-Bewertungen ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS tierarzt_bewertungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5), + wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5), + freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5), + kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5), + text TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(tierarzt_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt + ON tierarzt_bewertungen(tierarzt_id); + """) + + # ---- Feature: Foto-Challenge der Woche ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS foto_challenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thema TEXT NOT NULL, + beschreibung TEXT, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS challenge_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + foto_url TEXT NOT NULL, + caption TEXT, + votes INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(challenge_id, user_id) + ); + CREATE TABLE IF NOT EXISTS challenge_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(submission_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal + ON challenge_submissions(challenge_id, created_at DESC); + """) + logger.info("Migration: Foto-Challenge-Tabellen bereit.") + + # ---- Feature: Gassi-Zeiten-Pool ---- + conn.executescript(""" + CREATE TABLE IF NOT EXISTS gassi_zeiten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + wochentage TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + ort_name TEXT, + lat REAL, + lon REAL, + radius_m INTEGER DEFAULT 500, + notiz TEXT, + aktiv INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user + ON gassi_zeiten(user_id, aktiv); + """) + logger.info("Migration: Gassi-Zeiten-Tabelle bereit.") diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 48287f9..8448478 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -27,6 +27,14 @@ class TierarztCreate(BaseModel): osm_id: Optional[str] = None +class BewertungCreate(BaseModel): + gesamt: int + wartezeit: Optional[int] = None + freundlichkeit: Optional[int] = None + kompetenz: Optional[int] = None + text: Optional[str] = None + + class TierarztUpdate(BaseModel): name: Optional[str] = None strasse: Optional[str] = None @@ -220,3 +228,109 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate, ) row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() return dict(row) + + +# ------------------------------------------------------------------ +# BEWERTUNGEN +# ------------------------------------------------------------------ + +def _refresh_vet_rating(conn, tierarzt_id: int): + """Aktualisiert avg_rating und anz_bewertungen in tieraerzte.""" + row = conn.execute( + """SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg + FROM tierarzt_bewertungen WHERE tierarzt_id=?""", + (tierarzt_id,) + ).fetchone() + n = row["n"] or 0 + avg = row["avg"] or 0.0 + conn.execute( + "UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?", + (round(avg, 1), n, tierarzt_id) + ) + + +@router.post("/{tierarzt_id}/bewertung", status_code=201) +async def create_bewertung(tierarzt_id: int, data: BewertungCreate, + user=Depends(get_current_user)): + """Bewertung abgeben (1×pro User+Tierarzt, UPSERT).""" + if not (1 <= data.gesamt <= 5): + raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.") + for field in ("wartezeit", "freundlichkeit", "kompetenz"): + val = getattr(data, field) + if val is not None and not (1 <= val <= 5): + raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.") + + text = (data.text or "").strip()[:500] or None + + with db() as conn: + vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + conn.execute( + """INSERT INTO tierarzt_bewertungen + (tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET + gesamt=excluded.gesamt, + wartezeit=excluded.wartezeit, + freundlichkeit=excluded.freundlichkeit, + kompetenz=excluded.kompetenz, + text=excluded.text, + created_at=datetime('now')""", + (tierarzt_id, user["id"], data.gesamt, data.wartezeit, + data.freundlichkeit, data.kompetenz, text) + ) + _refresh_vet_rating(conn, tierarzt_id) + row = conn.execute( + "SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,) + ).fetchone() + return dict(row) + + +@router.get("/{tierarzt_id}/bewertungen") +async def list_bewertungen(tierarzt_id: int): + """Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte.""" + with db() as conn: + vet = conn.execute( + "SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?", + (tierarzt_id,) + ).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + # Stern-Verteilung + verteilung = {} + for star in range(1, 6): + r = conn.execute( + "SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?", + (tierarzt_id, star) + ).fetchone() + verteilung[str(star)] = r["n"] + + # Letzte 5 Kommentare + kommentare = conn.execute( + """SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at + FROM tierarzt_bewertungen + WHERE tierarzt_id=? AND text IS NOT NULL AND text != '' + ORDER BY created_at DESC LIMIT 5""", + (tierarzt_id,) + ).fetchall() + + return { + "avg_rating": vet["avg_rating"] or 0, + "anz_bewertungen": vet["anz_bewertungen"] or 0, + "verteilung": verteilung, + "kommentare": [dict(k) for k in kommentare], + } + + +@router.get("/{tierarzt_id}/meine-bewertung") +async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)): + """Eigene Bewertung für einen Tierarzt (oder null).""" + with db() as conn: + row = conn.execute( + "SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?", + (tierarzt_id, user["id"]) + ).fetchone() + return dict(row) if row else None diff --git a/backend/static/index.html b/backend/static/index.html index 37d6fcd..77f0433 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -507,6 +507,10 @@
+
+
+
+ @@ -570,7 +574,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index c6b26da..1071fdd 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -212,6 +212,9 @@ const API = (() => { osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); }, myFavorite() { return get('/tieraerzte/my-favorite'); }, toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); }, + bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); }, + meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); }, + bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 6308eda..bec107e 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -941,14 +941,30 @@ window.Page_health = (() => { _openNoteModal('health', id, label, null); }); }); - // Praxis öffnen + // Praxis öffnen → Detail-Modal mit Bewertungen content.querySelectorAll('[data-action="open-praxis"]').forEach(el => { el.addEventListener('click', () => { const id = parseInt(el.dataset.praxisId); const p = _praxen.find(x => x.id === id); + if (p) _showPraxisDetail(p); + }); + }); + // Praxis bearbeiten + content.querySelectorAll('[data-action="edit-praxis"]').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.praxisId); + const p = _praxen.find(x => x.id === id); if (p) _showPraxForm(p); }); }); + // Bewertung abgeben + content.querySelectorAll('[data-action="bewerten"]').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.praxisId); + const p = _praxen.find(x => x.id === id); + if (p) _showBewertungModal(p); + }); + }); // Dokument löschen content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => { btn.addEventListener('click', async () => { @@ -1642,6 +1658,14 @@ window.Page_health = (() => { const renderCard = p => { const isFav = _favoritVet?.id === p.id || p.is_favorite; + const hasRating = p.anz_bewertungen > 0; + const stars = hasRating ? _renderStarsReadonly(p.avg_rating) : ''; + const ratingHtml = hasRating + ? `
+ ${stars} + ${p.avg_rating.toFixed(1)} (${p.anz_bewertungen} Bew.) +
` + : `
Noch keine Bewertungen
`; return `
@@ -1660,6 +1684,7 @@ window.Page_health = (() => { ${_esc(_fmtOeffnungszeiten(p.opening_hours))}
` : ''} + ${ratingHtml}
${p.telefon ? ` { onclick="event.stopPropagation()"> Notfall ` : ''} + +
@@ -1716,6 +1756,226 @@ window.Page_health = (() => { `; } + // ---------------------------------------------------------- + // PRAXEN — Sterne-Hilfs-Funktionen + // ---------------------------------------------------------- + + /** Rendert 5 Sterne (readonly, filled bis `rating`). */ + function _renderStarsReadonly(rating) { + const full = Math.round(rating); + return Array.from({ length: 5 }, (_, i) => { + const filled = i < full; + return ``; + }).join(''); + } + + /** Rendert 5 klickbare Sterne mit data-val. */ + function _renderStarsInput(name, current) { + return `
+ ${Array.from({ length: 5 }, (_, i) => { + const val = i + 1; + const filled = current >= val; + return ``; + }).join('')} +
`; + } + + // ---------------------------------------------------------- + // PRAXEN — Detail-Modal (Bewertungen anzeigen) + // ---------------------------------------------------------- + async function _showPraxisDetail(praxis) { + // Erst mit Lade-Spinner öffnen, dann Daten laden + UI.modal.open({ + title: _esc(praxis.name), + body: `
+ +
`, + footer: ` + `, + }); + + document.getElementById('detail-bewerten-btn') + ?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); }); + + let data; + try { + data = await API.tieraerzte.bewertungen(praxis.id); + } catch { + UI.modal.open({ title: praxis.name, body: '

Bewertungen konnten nicht geladen werden.

' }); + return; + } + + const { avg_rating, anz_bewertungen, verteilung, kommentare } = data; + + // Balkendiagramm + const balken = [5, 4, 3, 2, 1].map(s => { + const n = verteilung[String(s)] || 0; + const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0; + return `
+ ${s} + +
+
+
+ ${n} +
`; + }).join(''); + + const kommentarHtml = kommentare.length + ? kommentare.map(k => ` +
+
+ ${_renderStarsReadonly(k.gesamt)} + + ${k.created_at ? k.created_at.slice(0, 10) : ''} + +
+ ${k.wartezeit || k.freundlichkeit || k.kompetenz ? ` +
+ ${k.wartezeit ? `Wartezeit: ${_renderStarsReadonly(k.wartezeit)}` : ''} + ${k.freundlichkeit ? `Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}` : ''} + ${k.kompetenz ? `Kompetenz: ${_renderStarsReadonly(k.kompetenz)}` : ''} +
` : ''} +

${_esc(k.text || '')}

+
`).join('') + : `

Noch keine Kommentare.

`; + + const bewBody = anz_bewertungen === 0 + ? `

+ Noch keine Bewertungen — sei der Erste! +

` + : ` +
+
+
${avg_rating.toFixed(1)}
+
${_renderStarsReadonly(avg_rating)}
+
${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}
+
+
${balken}
+
+
${kommentarHtml}
`; + + // Modal-Body aktualisieren (ohne Modal neu zu öffnen) + const modalBody = document.querySelector('.modal-body'); + if (modalBody) modalBody.innerHTML = bewBody; + } + + // ---------------------------------------------------------- + // PRAXEN — Bewertungs-Modal + // ---------------------------------------------------------- + async function _showBewertungModal(praxis) { + // Ggf. bestehende Bewertung laden + let existing = null; + try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ } + + const cur = existing || {}; + + const body = ` +
+
+ + ${_renderStarsInput('gesamt', cur.gesamt || 0)} + +
+
+ + ${_renderStarsInput('wartezeit', cur.wartezeit || 0)} + +
+
+ + ${_renderStarsInput('freundlichkeit', cur.freundlichkeit || 0)} + +
+
+ + ${_renderStarsInput('kompetenz', cur.kompetenz || 0)} + +
+
+ + +
max. 500 Zeichen
+
+
`; + + UI.modal.open({ + title: `${_esc(praxis.name)} bewerten`, + body, + footer: ` + + `, + }); + + // Sterne-Interaktion + document.querySelectorAll('.bew-stars').forEach(group => { + const name = group.dataset.name; + const hidden = document.getElementById(`bew-${name}`); + const stars = group.querySelectorAll('.bew-star'); + + const paint = val => { + stars.forEach(s => { + s.style.color = parseInt(s.dataset.val) <= val + ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'; + }); + }; + + stars.forEach(s => { + s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val))); + s.addEventListener('mouseleave', () => paint(parseInt(hidden.value))); + s.addEventListener('click', () => { + hidden.value = s.dataset.val; + paint(parseInt(s.dataset.val)); + }); + }); + + paint(parseInt(hidden.value)); + }); + + // Submit + document.getElementById('bew-submit-btn').addEventListener('click', async (e) => { + e.preventDefault(); + const form = document.getElementById('bew-form'); + const gesamt = parseInt(document.getElementById('bew-gesamt').value); + if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; } + + const payload = { gesamt }; + const wz = parseInt(document.getElementById('bew-wartezeit').value); + const fr = parseInt(document.getElementById('bew-freundlichkeit').value); + const ko = parseInt(document.getElementById('bew-kompetenz').value); + if (wz) payload.wartezeit = wz; + if (fr) payload.freundlichkeit = fr; + if (ko) payload.kompetenz = ko; + const txt = form.querySelector('textarea[name="text"]').value.trim(); + if (txt) payload.text = txt; + + await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => { + const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload); + // _praxen-Cache aktualisieren + _praxen = _praxen.map(p => + p.id === praxis.id + ? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen } + : p + ); + UI.modal.close(); + UI.toast.success('Bewertung gespeichert.'); + _renderTab(); + }); + }); + } + // ---------------------------------------------------------- // PRAXEN — Formular (Neu / Bearbeiten) // ----------------------------------------------------------