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

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