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"),
|
("users", "password_reset_expires", "TEXT"),
|
||||||
# Fell-Typ für personalisierte Wetter-Hinweise
|
# Fell-Typ für personalisierte Wetter-Hinweise
|
||||||
("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt
|
("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:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
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);
|
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
|
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):
|
class TierarztUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
strasse: 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()
|
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
|
||||||
return dict(row)
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=696">
|
<link rel="stylesheet" href="/css/design-system.css?v=700">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=696">
|
<link rel="stylesheet" href="/css/layout.css?v=700">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=696">
|
<link rel="stylesheet" href="/css/components.css?v=700">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -507,6 +507,10 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-reise">
|
||||||
|
<div class="page-body page-container"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||||
|
|
@ -570,7 +574,7 @@
|
||||||
<script src="/js/api.js?v=94"></script>
|
<script src="/js/api.js?v=94"></script>
|
||||||
<script src="/js/ui.js?v=94"></script>
|
<script src="/js/ui.js?v=94"></script>
|
||||||
<script src="/js/app.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 -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,9 @@ const API = (() => {
|
||||||
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
||||||
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
||||||
toggleFavorite(id) { return post(`/tieraerzte/${id}/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);
|
_openNoteModal('health', id, label, null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Praxis öffnen
|
// Praxis öffnen → Detail-Modal mit Bewertungen
|
||||||
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const id = parseInt(el.dataset.praxisId);
|
const id = parseInt(el.dataset.praxisId);
|
||||||
const p = _praxen.find(x => x.id === id);
|
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);
|
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
|
// Dokument löschen
|
||||||
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
|
content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
|
@ -1642,6 +1658,14 @@ window.Page_health = (() => {
|
||||||
|
|
||||||
const renderCard = p => {
|
const renderCard = p => {
|
||||||
const isFav = _favoritVet?.id === p.id || p.is_favorite;
|
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 `
|
return `
|
||||||
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||||||
data-praxis-id="${p.id}" data-action="open-praxis">
|
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>
|
<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))}
|
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${ratingHtml}
|
||||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
||||||
${p.telefon ? `
|
${p.telefon ? `
|
||||||
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
|
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
|
||||||
|
|
@ -1671,6 +1696,14 @@ window.Page_health = (() => {
|
||||||
onclick="event.stopPropagation()">
|
onclick="event.stopPropagation()">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
||||||
</a>` : ''}
|
</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'}"
|
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
|
||||||
data-action="toggle-fav" data-praxis-id="${p.id}"
|
data-action="toggle-fav" data-praxis-id="${p.id}"
|
||||||
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
|
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
|
||||||
|
|
@ -1681,6 +1714,13 @@ window.Page_health = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
|
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
|
||||||
</button>
|
</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>
|
</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)
|
// PRAXEN — Formular (Neu / Bearbeiten)
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue