Feature: Tierarzt-Bewertungen — Sterne-Rating pro Praxis mit Detail-Modal (SW by-v700)

This commit is contained in:
rene 2026-05-04 21:02:49 +02:00
parent c5030024b0
commit 40de0f38aa
5 changed files with 461 additions and 5 deletions

View file

@ -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.")

View file

@ -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

View file

@ -93,9 +93,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=696">
<link rel="stylesheet" href="/css/layout.css?v=696">
<link rel="stylesheet" href="/css/components.css?v=696">
<link rel="stylesheet" href="/css/design-system.css?v=700">
<link rel="stylesheet" href="/css/layout.css?v=700">
<link rel="stylesheet" href="/css/components.css?v=700">
</head>
<body>
@ -507,6 +507,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-reise">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
@ -570,7 +574,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=696"></script>
<script src="/js/worlds.js?v=700"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -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); },
};
// ----------------------------------------------------------

View file

@ -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
? `<div style="display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-1);font-size:var(--text-sm)">
${stars}
<span style="color:var(--c-text-secondary)">${p.avg_rating.toFixed(1)} (${p.anz_bewertungen} Bew.)</span>
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">Noch keine Bewertungen</div>`;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
@ -1660,6 +1684,7 @@ window.Page_health = (() => {
<svg class="ph-icon" aria-hidden="true" style="font-size:0.9em"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
</div>` : ''}
${ratingHtml}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? `
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
@ -1671,6 +1696,14 @@ window.Page_health = (() => {
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm btn-secondary"
data-action="bewerten" data-praxis-id="${p.id}"
title="Bewertung abgeben"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Bewerten
</button>
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
@ -1681,6 +1714,13 @@ window.Page_health = (() => {
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
<button class="btn btn-sm btn-secondary"
data-action="edit-praxis" data-praxis-id="${p.id}"
title="Praxis bearbeiten"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>
</div>
</div>
</div>
@ -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 `<span aria-hidden="true" style="font-size:1em;color:${filled ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'}">&#9733;</span>`;
}).join('');
}
/** Rendert 5 klickbare Sterne mit data-val. */
function _renderStarsInput(name, current) {
return `<div class="bew-stars" data-name="${name}" role="group" aria-label="Bewertung ${name}"
style="display:flex;gap:2px;cursor:pointer">
${Array.from({ length: 5 }, (_, i) => {
const val = i + 1;
const filled = current >= val;
return `<span class="bew-star" data-val="${val}"
style="font-size:1.6rem;color:${filled ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)'};
transition:color .1s">&#9733;</span>`;
}).join('')}
</div>`;
}
// ----------------------------------------------------------
// 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: `<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon spin" aria-hidden="true" style="font-size:2rem">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="detail-bewerten-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Jetzt bewerten
</button>`,
});
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: '<p>Bewertungen konnten nicht geladen werden.</p>' });
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 `<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm)">
<span style="min-width:1.2em;text-align:right">${s}</span>
<span aria-hidden="true" style="color:var(--c-warning,#f59e0b);font-size:.9em">&#9733;</span>
<div style="flex:1;height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:var(--c-warning,#f59e0b);border-radius:4px"></div>
</div>
<span style="min-width:2em;color:var(--c-text-secondary)">${n}</span>
</div>`;
}).join('');
const kommentarHtml = kommentare.length
? kommentare.map(k => `
<div style="padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-1)">
${_renderStarsReadonly(k.gesamt)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
${k.created_at ? k.created_at.slice(0, 10) : ''}
</span>
</div>
${k.wartezeit || k.freundlichkeit || k.kompetenz ? `
<div style="display:flex;gap:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">
${k.wartezeit ? `<span>Wartezeit: ${_renderStarsReadonly(k.wartezeit)}</span>` : ''}
${k.freundlichkeit ? `<span>Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}</span>` : ''}
${k.kompetenz ? `<span>Kompetenz: ${_renderStarsReadonly(k.kompetenz)}</span>` : ''}
</div>` : ''}
<p style="margin:0;font-size:var(--text-sm)">${_esc(k.text || '')}</p>
</div>`).join('')
: `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Kommentare.</p>`;
const bewBody = anz_bewertungen === 0
? `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-4) 0">
Noch keine Bewertungen sei der Erste!
</p>`
: `
<div style="display:flex;align-items:center;gap:var(--space-4);margin-bottom:var(--space-4)">
<div style="text-align:center">
<div style="font-size:3rem;font-weight:700;line-height:1">${avg_rating.toFixed(1)}</div>
<div>${_renderStarsReadonly(avg_rating)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}</div>
</div>
<div style="flex:1">${balken}</div>
</div>
<div>${kommentarHtml}</div>`;
// 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 = `
<form id="bew-form">
<div class="form-group">
<label class="form-label" style="font-weight:600">Gesamteindruck *</label>
${_renderStarsInput('gesamt', cur.gesamt || 0)}
<input type="hidden" name="gesamt" id="bew-gesamt" value="${cur.gesamt || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Wartezeit</label>
${_renderStarsInput('wartezeit', cur.wartezeit || 0)}
<input type="hidden" name="wartezeit" id="bew-wartezeit" value="${cur.wartezeit || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Freundlichkeit</label>
${_renderStarsInput('freundlichkeit', cur.freundlichkeit || 0)}
<input type="hidden" name="freundlichkeit" id="bew-freundlichkeit" value="${cur.freundlichkeit || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Kompetenz</label>
${_renderStarsInput('kompetenz', cur.kompetenz || 0)}
<input type="hidden" name="kompetenz" id="bew-kompetenz" value="${cur.kompetenz || 0}">
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Kommentar <span style="font-weight:400;color:var(--c-text-muted)">(optional, anonym)</span></label>
<textarea class="form-control" name="text" maxlength="500" rows="3"
placeholder="Deine Erfahrungen mit dieser Praxis…">${_esc(cur.text || '')}</textarea>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:right">max. 500 Zeichen</div>
</div>
</form>`;
UI.modal.open({
title: `${_esc(praxis.name)} bewerten`,
body,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="bew-submit-btn" form="bew-form">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
${existing ? 'Bewertung aktualisieren' : 'Bewertung abgeben'}
</button>`,
});
// 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)
// ----------------------------------------------------------