Feature: Tierarzt-Bewertungen — Sterne-Rating pro Praxis mit Detail-Modal (SW by-v700)
This commit is contained in:
parent
c5030024b0
commit
40de0f38aa
5 changed files with 461 additions and 5 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)'}">★</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">★</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">★</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)
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue