From fc0f48c6d03e546bbcdc6a3556a1d043cdc3712b Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 13 Apr 2026 20:06:59 +0200 Subject: [PATCH] =?UTF-8?q?Feat:=20Tier=C3=A4rzte-Verwaltung=20(Sprint=204?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Praxen-Tab in Gesundheit: Tierarzt-Stammdaten (Name, Adresse, Telefon, Notfall-Nr, E-Mail, Website, Notizen), Anruf- und Notfall-Schnellzugriff via tel:-Links, Soft-Delete (aktiv=0) für Praxiswechsel ohne Datenverlust. Tierarzt-Dropdown beim Eintragen von Tierarzt-Besuchen. SW-Cache → by-v7. --- backend/database.py | 18 +++ backend/main.py | 30 ++-- backend/routes/health.py | 9 +- backend/routes/tieraerzte.py | 91 +++++++++++ backend/static/js/api.js | 11 +- backend/static/js/pages/health.js | 252 +++++++++++++++++++++++++++--- backend/static/sw.js | 2 +- 7 files changed, 371 insertions(+), 42 deletions(-) create mode 100644 backend/routes/tieraerzte.py diff --git a/backend/database.py b/backend/database.py index d268de4..95e5fef 100644 --- a/backend/database.py +++ b/backend/database.py @@ -249,6 +249,23 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + -- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug) + CREATE TABLE IF NOT EXISTS tieraerzte ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + adresse TEXT, + telefon TEXT, + notfall_telefon TEXT, + email TEXT, + website TEXT, + notizen TEXT, + ist_notfallpraxis INTEGER NOT NULL DEFAULT 0, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_tieraerzte_user ON tieraerzte(user_id, aktiv); + """) # Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent) @@ -277,6 +294,7 @@ def _migrate(conn_factory): ("health", "reaktion", "TEXT"), ("health", "datei_url", "TEXT"), ("health", "datei_typ", "TEXT"), + ("health", "tierarzt_id", "INTEGER"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index 88c7e1e..9769399 100644 --- a/backend/main.py +++ b/backend/main.py @@ -46,21 +46,23 @@ app = FastAPI( # ------------------------------------------------------------------ # API-Router registrieren (werden nach und nach hinzugefügt) # ------------------------------------------------------------------ -from routes.auth import router as auth_router -from routes.dogs import router as dogs_router -from routes.diary import router as diary_router -from routes.health import router as health_router -from routes.poison import router as poison_router -from routes.push import router as push_router -from routes.ki import router as ki_router +from routes.auth import router as auth_router +from routes.dogs import router as dogs_router +from routes.diary import router as diary_router +from routes.health import router as health_router +from routes.poison import router as poison_router +from routes.push import router as push_router +from routes.ki import router as ki_router +from routes.tieraerzte import router as tieraerzte_router -app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) -app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) -app.include_router(diary_router, prefix="/api/dogs", tags=["Tagebuch"]) -app.include_router(health_router, prefix="/api/dogs", tags=["Gesundheit"]) -app.include_router(poison_router, prefix="/api/poison", tags=["Giftköder"]) -app.include_router(push_router, prefix="/api/push", tags=["Push"]) -app.include_router(ki_router, prefix="/api/ki", tags=["KI"]) +app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) +app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) +app.include_router(diary_router, prefix="/api/dogs", tags=["Tagebuch"]) +app.include_router(health_router, prefix="/api/dogs", tags=["Gesundheit"]) +app.include_router(poison_router, prefix="/api/poison", tags=["Giftköder"]) +app.include_router(push_router, prefix="/api/push", tags=["Push"]) +app.include_router(ki_router, prefix="/api/ki", tags=["KI"]) +app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"]) # ------------------------------------------------------------------ diff --git a/backend/routes/health.py b/backend/routes/health.py index 8911d99..c9c9e20 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -42,6 +42,8 @@ class HealthCreate(BaseModel): schweregrad: Optional[str] = None # leicht | mittel | schwer reaktion: Optional[str] = None erinnerung: Optional[int] = 1 + # Tierarzt-Verknüpfung + tierarzt_id: Optional[int] = None class HealthUpdate(BaseModel): @@ -62,6 +64,7 @@ class HealthUpdate(BaseModel): schweregrad: Optional[str] = None reaktion: Optional[str] = None erinnerung: Optional[int] = None + tierarzt_id: Optional[int] = None # ------------------------------------------------------------------ @@ -113,13 +116,13 @@ async def create_health(dog_id: int, data: HealthCreate, (dog_id, typ, bezeichnung, datum, naechstes, notiz, wert, einheit, charge_nr, tierarzt_name, kosten, diagnose, dosierung, haeufigkeit, aktiv, bis_datum, - schweregrad, reaktion, erinnerung) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + schweregrad, reaktion, erinnerung, tierarzt_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes, data.notiz, data.wert, data.einheit, data.charge_nr, data.tierarzt_name, data.kosten, data.diagnose, data.dosierung, data.haeufigkeit, data.aktiv, data.bis_datum, - data.schweregrad, data.reaktion, data.erinnerung) + data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id) ) row = conn.execute( "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py new file mode 100644 index 0000000..3b2a340 --- /dev/null +++ b/backend/routes/tieraerzte.py @@ -0,0 +1,91 @@ +"""BAN YARO — Tierärzte Routes (user-level, nie löschen)""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + + +class TierarztCreate(BaseModel): + name: str + adresse: Optional[str] = None + telefon: Optional[str] = None + notfall_telefon: Optional[str] = None + email: Optional[str] = None + website: Optional[str] = None + notizen: Optional[str] = None + ist_notfallpraxis: bool = False + + +class TierarztUpdate(BaseModel): + name: Optional[str] = None + adresse: Optional[str] = None + telefon: Optional[str] = None + notfall_telefon: Optional[str] = None + email: Optional[str] = None + website: Optional[str] = None + notizen: Optional[str] = None + ist_notfallpraxis: Optional[bool] = None + aktiv: Optional[bool] = None # False = inaktiv (Umzug etc.) + + +@router.get("") +async def list_tieraerzte(user=Depends(get_current_user)): + """Alle Tierärzte des Users — aktive zuerst, dann inaktive (für Historienansicht).""" + with db() as conn: + rows = conn.execute( + "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", + (user["id"],) + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("", status_code=201) +async def create_tierarzt(data: TierarztCreate, user=Depends(get_current_user)): + with db() as conn: + conn.execute( + """INSERT INTO tieraerzte + (user_id, name, adresse, telefon, notfall_telefon, + email, website, notizen, ist_notfallpraxis) + VALUES (?,?,?,?,?,?,?,?,?)""", + (user["id"], data.name, data.adresse, data.telefon, + data.notfall_telefon, data.email, data.website, + data.notizen, int(data.ist_notfallpraxis)) + ) + row = conn.execute( + "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],) + ).fetchone() + return dict(row) + + +@router.patch("/{tierarzt_id}") +async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate, + user=Depends(get_current_user)): + with db() as conn: + entry = conn.execute( + "SELECT id FROM tieraerzte WHERE id=? AND user_id=?", + (tierarzt_id, user["id"]) + ).fetchone() + if not entry: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + updates = {k: v for k, v in data.model_dump().items() if v is not None} + if "ist_notfallpraxis" in updates: + updates["ist_notfallpraxis"] = int(updates["ist_notfallpraxis"]) + if "aktiv" in updates: + updates["aktiv"] = int(updates["aktiv"]) + if not updates: + row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() + return dict(row) + + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE tieraerzte SET {set_clause} WHERE id=?", + list(updates.values()) + [tierarzt_id] + ) + row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone() + return dict(row) diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 0fa9719..aee3b52 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -134,6 +134,15 @@ const API = (() => { }, }; + // ---------------------------------------------------------- + // TIERÄRZTE + // ---------------------------------------------------------- + const tieraerzte = { + list() { return get('/tieraerzte'); }, + create(data) { return post('/tieraerzte', data); }, + update(id, d) { return patch(`/tieraerzte/${id}`, d); }, + }; + // ---------------------------------------------------------- // GIFTKÖDER-ALARM // ---------------------------------------------------------- @@ -249,7 +258,7 @@ const API = (() => { // Öffentliche API return { get, post, put, patch, del, upload, - auth, dogs, diary, health, poison, + auth, dogs, diary, health, tieraerzte, poison, places, routes, weather, push, subscribeToPush, getLocation, APIError, diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 2214c12..5a54bf8 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -9,15 +9,17 @@ window.Page_health = (() => { let _container = null; let _appState = null; let _data = {}; // { impfung:[], tierarzt:[], gewicht:[], medikament:[], allergie:[], dokument:[] } + let _praxen = []; let _activeTab = 'impfung'; const TABS = [ { key: 'impfung', label: 'Impfpass', icon: '💉' }, - { key: 'tierarzt', label: 'Tierarzt', icon: '🏥' }, + { key: 'tierarzt', label: 'Tierarzt', icon: '🩺' }, { key: 'gewicht', label: 'Gewicht', icon: '⚖️' }, { key: 'medikament', label: 'Medikamente',icon: '💊' }, { key: 'allergie', label: 'Allergien', icon: '🌿' }, { key: 'dokument', label: 'Dokumente', icon: '📄' }, + { key: 'praxen', label: 'Praxen', icon: '🏥' }, ]; // ---------------------------------------------------------- @@ -165,6 +167,11 @@ window.Page_health = (() => { } catch (err) { UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.'); } + try { + _praxen = await API.tieraerzte.list(); + } catch (err) { + // silent fail + } } // ---------------------------------------------------------- @@ -183,6 +190,7 @@ window.Page_health = (() => { case 'medikament': content.innerHTML = _renderMedikamente(entries); break; case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break; + case 'praxen': content.innerHTML = _renderPraxen(); break; } _bindTabEvents(content); @@ -438,6 +446,17 @@ window.Page_health = (() => { const entry = (_data[_activeTab] || []).find(e => e.id === id); if (entry) card.addEventListener('click', () => _openDetail(entry)); }); + // Praxis öffnen + 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) _showPraxForm(p); + }); + }); + // Praxis hinzufügen + content.querySelector('[data-action="add-praxis"]') + ?.addEventListener('click', () => _showPraxForm(null)); } // ---------------------------------------------------------- @@ -564,7 +583,20 @@ window.Page_health = (() => { UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body }); const form = document.getElementById('health-form'); - setTimeout(() => form?.querySelector('[name="bezeichnung"]')?.focus(), 150); + setTimeout(() => { + form?.querySelector('[name="bezeichnung"]')?.focus(); + // Praxis-Dropdown: Name auto-befüllen + const praxisSelect = document.getElementById('health-praxis-select'); + const nameInput = document.getElementById('health-tierarzt-name-input'); + if (praxisSelect && nameInput) { + praxisSelect.addEventListener('change', () => { + const selected = praxisSelect.options[praxisSelect.selectedIndex]; + if (selected.value) { + nameInput.value = selected.dataset.name || selected.textContent.trim(); + } + }); + } + }, 150); document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close); @@ -646,25 +678,42 @@ window.Page_health = (() => { `; - case 'tierarzt': return ` -
- - -
-
- - -
-
- - -
-
- - -
- `; + case 'tierarzt': { + const aktivePraxen = _praxen.filter(p => p.aktiv); + const praxisDropdown = aktivePraxen.length ? ` +
+ + +
` : ''; + return ` + ${praxisDropdown} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + } case 'gewicht': return `
@@ -728,8 +777,9 @@ window.Page_health = (() => { schweregrad: fd.schweregrad || null, reaktion: fd.reaktion || null, }; - if (fd.wert) p.wert = parseFloat(fd.wert); - if (fd.kosten) p.kosten = parseFloat(fd.kosten); + if (fd.wert) p.wert = parseFloat(fd.wert); + if (fd.kosten) p.kosten = parseFloat(fd.kosten); + if (fd.tierarzt_id) p.tierarzt_id = parseInt(fd.tierarzt_id); if (typ === 'medikament') { p.aktiv = 'aktiv' in fd ? 1 : 0; } @@ -738,6 +788,162 @@ window.Page_health = (() => { return p; } + // ---------------------------------------------------------- + // PRAXEN — Liste + // ---------------------------------------------------------- + function _renderPraxen() { + const addBtn = ``; + + const aktive = _praxen.filter(p => p.aktiv); + const inaktive = _praxen.filter(p => !p.aktiv); + + if (!_praxen.length) return UI.emptyState({ + icon: '🏥', title: 'Noch keine Praxis eingetragen', + text: 'Trage deine Tierarztpraxis ein für schnellen Zugriff.', + action: addBtn + }); + + const renderCard = p => ` +
+
${p.ist_notfallpraxis ? '🚨' : '🏥'}
+
+
+ ${_esc(p.name)} + ${!p.aktiv ? ' · Ehemalig' : ''} +
+ ${p.adresse ? `
${_esc(p.adresse)}
` : ''} +
+ ${p.telefon ? ` + + 📞 Anrufen + ` : ''} + ${p.notfall_telefon ? ` + + 🚨 Notfall + ` : ''} +
+
+
+ `; + + return ` +
+ ${addBtn} +
+
+ ${aktive.map(renderCard).join('')} + ${inaktive.length ? ` +
+

Ehemalige Praxen

+ ${inaktive.map(renderCard).join('')} +
` : ''} +
+ `; + } + + // ---------------------------------------------------------- + // PRAXEN — Formular (Neu / Bearbeiten) + // ---------------------------------------------------------- + function _showPraxForm(praxis) { + const isEdit = !!praxis; + UI.modal.open({ + title: isEdit ? `${praxis.name} bearbeiten` : 'Praxis hinzufügen', + body: ` +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ ${isEdit ? ` +
+ +
` : ''} +
+ + +
+
+ `, + }); + + document.getElementById('praxis-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('praxis-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + + await UI.asyncButton(btn, async () => { + const payload = { + name: fd.name?.trim(), + adresse: fd.adresse || null, + telefon: fd.telefon || null, + notfall_telefon: fd.notfall_telefon || null, + email: fd.email || null, + notizen: fd.notizen || null, + ist_notfallpraxis: 'ist_notfallpraxis' in fd, + }; + if (!payload.name) { UI.toast.warning('Bitte einen Namen eingeben.'); return; } + + let saved; + if (isEdit) { + payload.aktiv = !('inaktiv' in fd); + saved = await API.tieraerzte.update(praxis.id, payload); + _praxen = _praxen.map(p => p.id === praxis.id ? saved : p); + UI.toast.success('Praxis gespeichert.'); + } else { + saved = await API.tieraerzte.create(payload); + _praxen.push(saved); + UI.toast.success(`${saved.name} hinzugefügt.`); + } + UI.modal.close(); + _renderTab(); + }); + }); + } + // ---------------------------------------------------------- // KI-ZUSAMMENFASSUNG // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 3801d8e..a0930cc 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications ============================================================ */ -const CACHE_VERSION = 'by-v6'; +const CACHE_VERSION = 'by-v7'; const CACHE_STATIC = `${CACHE_VERSION}-static`; // Diese Dateien werden beim Install gecacht (App Shell)