Compare commits

...

3 commits

Author SHA1 Message Date
d8b9561fff Frontend Sprint 3+4: Dog-Switcher, Health-Seite, Multi-Dog Tagebuch
- app.js: vollständiger Dog-Switcher (Avatar im Header/Sidebar, Quickpicker
  bei 3+ Hunden, setActiveDog, localStorage-Persistenz), iOS Ghost-Click Fix,
  Loading-Guard, Logout State Reset
- index.html: Dog-Switcher HTML, Favicon-Links, Sidebar "+ Neu erstellen",
  Navigation Tab Karte → Gesundheit
- health.js (neu): vollständiges Health-Frontend mit Tabs (Impfung, Entwurmung,
  Tierarzt, Medikament, Gewicht-Kurve, Allergie, Dokument), Ampel-System,
  KI-Zusammenfassung
- dog-profile.js: "+ Weiteren Hund anlegen" Button + _openCreateModal(),
  Event-Delegation statt direkter Listener (kein Doppelaufruf)
- diary.js: Dog-Picker im Formular, Avatar-Reihe auf Karten, Dog-Chips
  im Detail-Modal, dog_ids im API-Payload
- poison.js: Erledigt-Dialog mit Grundauswahl (beseitigt/fehlerhaft/anderes)
- api.js: health-Endpoints (list, create, update, delete, upload, ki)
- ui.js: confirm() Fix (resolve vor close)
- layout.css: Dog-Switcher Styles, scrollbare Sidebar-Nav, User-Item fix
- components.css: Health-Styles, Diary Dog-Picker, Ampel-Punkte, Gewicht-SVG
- icons/: Favicon-Set (ico, 16px, 32px, 180px, 192px, 512px)
2026-04-13 19:30:03 +02:00
6f48ec581d Backend Sprint 2+3: Health-Modul, Multi-Dog Tagebuch, Pillow, Migrations
- database.py: diary_dogs + walk_participant_dogs Tabellen, idempotente
  Migration für Health-Felder (charge_nr, kosten, diagnose, …), Backfill
- routes/health.py: vollständiges Health-Modul (war Stub), CRUD für
  Impfung/Entwurmung/Tierarzt/Medikament/Gewicht/Allergie/Dokument
- routes/diary.py: Multi-Dog n:m via diary_dogs (dog_ids in allen Endpoints)
- routes/dogs.py: Foto-Upload konvertiert HEIC/PNG/WebP → JPEG via Pillow
- routes/poison.py: Resolve mit Grundauswahl + Soft-Delete (geloest_von/at/grund)
- ki.py: health_summary() für KI-Gesundheitsbericht
- main.py: /favicon.ico Route
- requirements.txt: Pillow 11.2.1 + pillow-heif 0.22.0
2026-04-13 19:29:51 +02:00
96e7a97b52 Infra: Container-Name ban-yaro→banyaro, Favicon-Route, Cache-Bust v3
- Makefile + docker-compose.yml: container_name ban-yaro → banyaro
- sw.js: Cache-Version auf by-v3 (neue Icons + Health-Assets)
- .gitignore: /icons/ (Design-Quell-Dateien) ausschließen
2026-04-13 19:29:43 +02:00
28 changed files with 2174 additions and 147 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ __pycache__/
*.db *.db
*.db-wal *.db-wal
*.db-shm *.db-shm
# Design-Quell-Dateien (nicht für Server)
/icons/

View file

@ -8,8 +8,8 @@ DS_HOST := ds
DS_IP := 10.47.11.10 DS_IP := 10.47.11.10
# Hinweis: NPM braucht 10.47.11.99 als Forward-IP (Macvlan-Shim), nicht .10 # Hinweis: NPM braucht 10.47.11.99 als Forward-IP (Macvlan-Shim), nicht .10
DS_SSH_PORT := 22 DS_SSH_PORT := 22
DS_PATH := /volume1/docker/ban-yaro DS_PATH := /volume1/docker/banyaro
CONTAINER := ban-yaro # container_name (für docker logs/exec) CONTAINER := banyaro # container_name (für docker logs/exec)
SERVICE := banyaro # service-name in docker-compose.yml (für docker compose restart) SERVICE := banyaro # service-name in docker-compose.yml (für docker compose restart)
GIT_REMOTE := origin GIT_REMOTE := origin
DOCKER := sudo /usr/local/bin/docker DOCKER := sudo /usr/local/bin/docker

View file

@ -88,6 +88,14 @@ def init_db():
); );
CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC); CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC);
-- TAGEBUCH HUNDE (n:m ein Eintrag kann mehrere Hunde betreffen)
CREATE TABLE IF NOT EXISTS diary_dogs (
diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
PRIMARY KEY (diary_id, dog_id)
);
CREATE INDEX IF NOT EXISTS idx_diary_dogs_dog ON diary_dogs(dog_id);
-- GESUNDHEIT -- GESUNDHEIT
CREATE TABLE IF NOT EXISTS health ( CREATE TABLE IF NOT EXISTS health (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -186,6 +194,15 @@ def init_db():
PRIMARY KEY (walk_id, user_id) PRIMARY KEY (walk_id, user_id)
); );
-- GASSI-TREFFEN HUNDE (n:m Teilnehmer kann mehrere Hunde mitbringen)
CREATE TABLE IF NOT EXISTS walk_participant_dogs (
walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
PRIMARY KEY (walk_id, user_id, dog_id)
);
CREATE INDEX IF NOT EXISTS idx_wpd_dog ON walk_participant_dogs(dog_id);
-- FORUM -- FORUM
CREATE TABLE IF NOT EXISTS forum_threads ( CREATE TABLE IF NOT EXISTS forum_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -234,4 +251,48 @@ def init_db():
""") """)
# Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent)
_migrate(conn_factory=db)
logger.info("Datenbank initialisiert.") logger.info("Datenbank initialisiert.")
def _migrate(conn_factory):
"""Fügt fehlende Spalten hinzu und führt Datenmigration durch (idempotent)."""
migrations = [
# Giftköder: Auflösungs-Details für spätere KI-Analyse
("poison", "geloest_von", "INTEGER"),
("poison", "geloest_at", "TEXT"),
("poison", "geloest_grund", "TEXT"),
# Gesundheit: erweiterte Felder je nach Eintragstyp
("health", "charge_nr", "TEXT"),
("health", "tierarzt_name", "TEXT"),
("health", "kosten", "REAL"),
("health", "diagnose", "TEXT"),
("health", "dosierung", "TEXT"),
("health", "haeufigkeit", "TEXT"),
("health", "aktiv", "INTEGER NOT NULL DEFAULT 1"),
("health", "bis_datum", "TEXT"),
("health", "schweregrad", "TEXT"),
("health", "reaktion", "TEXT"),
("health", "datei_url", "TEXT"),
("health", "datei_typ", "TEXT"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
existing = [
row[1] for row in
conn.execute(f"PRAGMA table_info({table})").fetchall()
]
if column not in existing:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"
)
logger.info(f"Migration: {table}.{column} hinzugefügt.")
# Datenmigration: diary_dogs für bestehende Einträge befüllen
conn.execute("""
INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id)
SELECT id, dog_id FROM diary
""")
logger.info("Migration: diary_dogs Backfill abgeschlossen.")

View file

@ -289,6 +289,71 @@ Bewerte als JSON:
return {"plausibel": True, "typ": "unbekannt", "hinweis": description} return {"plausibel": True, "typ": "unbekannt", "hinweis": description}
async def health_summary(health_data: list, dog_info: dict,
user_is_premium: bool = False) -> str:
"""
Tierärztliche Zusammenfassung aller Gesundheitsdaten lokal für alle.
Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen.
"""
system = (
"Du bist ein erfahrener Veterinär-Assistent. "
"Erstelle präzise, strukturierte Gesundheitsberichte für Hunde auf Deutsch. "
"Weise auf fällige Impfungen und wichtige Termine hin."
)
# Daten nach Typ gruppieren für den Prompt
def _fmt(entries, typ):
subset = [e for e in entries if e.get("typ") == typ]
if not subset:
return " (keine Einträge)"
lines = []
for e in subset[:10]: # maximal 10 pro Typ
line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
if e.get("naechstes"):
line += f" (nächste Fälligkeit: {e['naechstes']})"
if e.get("notiz"):
line += f"{e['notiz']}"
lines.append(line)
return "\n".join(lines)
heute = __import__("datetime").date.today().isoformat()
prompt = f"""
Hund: {dog_info.get('name')}, {dog_info.get('rasse', 'unbekannt')},
Geburtstag: {dog_info.get('geburtstag', 'unbekannt')}, Gewicht: {dog_info.get('gewicht_kg', '?')} kg
Heutiges Datum: {heute}
=== IMPFUNGEN ===
{_fmt(health_data, 'impfung')}
=== ENTWURMUNG ===
{_fmt(health_data, 'entwurmung')}
=== TIERARZTBESUCHE ===
{_fmt(health_data, 'tierarzt')}
=== MEDIKAMENTE ===
{_fmt(health_data, 'medikament')}
=== ALLERGIEN ===
{_fmt(health_data, 'allergie')}
Erstelle einen strukturierten Gesundheitsbericht mit:
1. Aktueller Status (2-3 Sätze)
2. Fällige / überfällige Impfungen und Termine
3. Wichtige Hinweise für den nächsten Tierarztbesuch
4. Allgemeine Empfehlungen
Schreibe klar und verständlich für den Hundebesitzer.
"""
return await complete(
prompt, system,
max_tokens=700,
requires_premium=False, # Kostenlos für alle
user_is_premium=user_is_premium,
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Status-Endpoint (für Admin/Debug) # Status-Endpoint (für Admin/Debug)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -89,6 +89,10 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True) os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@app.get("/favicon.ico")
async def favicon():
return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
@app.get("/manifest.json") @app.get("/manifest.json")
async def manifest(): async def manifest():
return FileResponse(f"{STATIC_DIR}/manifest.json") return FileResponse(f"{STATIC_DIR}/manifest.json")

View file

@ -1,4 +1,6 @@
fastapi==0.115.0 fastapi==0.115.0
Pillow==11.2.1
pillow-heif==0.22.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
python-multipart==0.0.9 python-multipart==0.0.9
pydantic[email]==2.8.2 pydantic[email]==2.8.2

View file

@ -21,12 +21,15 @@ class DiaryCreate(BaseModel):
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: Optional[float] = None gps_lon: Optional[float] = None
is_milestone: bool = False is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
class DiaryUpdate(BaseModel): class DiaryUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
tags: Optional[list] = None tags: Optional[list] = None
is_milestone: Optional[bool] = None is_milestone: Optional[bool] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
def _own_dog(dog_id: int, user_id: int, conn): def _own_dog(dog_id: int, user_id: int, conn):
@ -38,23 +41,63 @@ def _own_dog(dog_id: int, user_id: int, conn):
return dog return dog
def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]:
"""Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück."""
all_ids = list({primary} | set(dog_ids))
for did in all_ids:
_own_dog(did, user_id, conn)
return all_ids
def _fetch_dog_ids(conn, entry_ids: list[int]) -> dict:
"""Gibt {entry_id: [dog_id, ...]} zurück."""
if not entry_ids:
return {}
ph = ",".join("?" * len(entry_ids))
rows = conn.execute(
f"SELECT diary_id, dog_id FROM diary_dogs WHERE diary_id IN ({ph})",
entry_ids
).fetchall()
result = {}
for r in rows:
result.setdefault(r["diary_id"], []).append(r["dog_id"])
return result
def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
conn.execute("DELETE FROM diary_dogs WHERE diary_id=?", (entry_id,))
for did in dog_ids:
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(entry_id, did)
)
def _entry_dict(row, dog_ids_map: dict) -> dict:
e = dict(row)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
return e
@router.get("/{dog_id}/diary") @router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
user=Depends(get_current_user)): user=Depends(get_current_user)):
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
# Einträge des primären Hundes SOWIE Einträge wo der Hund als weiterer zugeordnet ist
rows = conn.execute( rows = conn.execute(
"""SELECT * FROM diary WHERE dog_id=? """SELECT DISTINCT d.* FROM diary d
ORDER BY datum DESC, created_at DESC LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.dog_id = ? OR dd.dog_id = ?
ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""", LIMIT ? OFFSET ?""",
(dog_id, limit, offset) (dog_id, dog_id, limit, offset)
).fetchall() ).fetchall()
entries = [] ids = [r["id"] for r in rows]
for r in rows: dogs_map = _fetch_dog_ids(conn, ids)
e = dict(r)
e["tags"] = json.loads(e["tags"]) if e["tags"] else [] return [_entry_dict(r, dogs_map) for r in rows]
entries.append(e)
return entries
@router.post("/{dog_id}/diary", status_code=201) @router.post("/{dog_id}/diary", status_code=201)
@ -72,6 +115,8 @@ async def create_diary(dog_id: int, data: DiaryCreate,
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
conn.execute( conn.execute(
"""INSERT INTO diary """INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone) (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
@ -85,10 +130,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", "SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
(dog_id,) (dog_id,)
).fetchone() ).fetchone()
_set_dog_ids(conn, entry["id"], all_dogs)
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
e = dict(entry) return _entry_dict(entry, dogs_map)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
return e
@router.get("/{dog_id}/diary/{entry_id}") @router.get("/{dog_id}/diary/{entry_id}")
@ -96,13 +141,16 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
row = conn.execute( row = conn.execute(
"SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) """SELECT DISTINCT d.* FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
(entry_id, dog_id, dog_id)
).fetchone() ).fetchone()
if not row: if not row:
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")
e = dict(row) dogs_map = _fetch_dog_ids(conn, [entry_id])
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
return e return _entry_dict(row, dogs_map)
@router.patch("/{dog_id}/diary/{entry_id}") @router.patch("/{dog_id}/diary/{entry_id}")
@ -110,20 +158,43 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
user=Depends(get_current_user)): user=Depends(get_current_user)):
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
fields = {k: v for k, v in data.model_dump().items() if v is not None}
# Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs)
exists = conn.execute(
"""SELECT 1 FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
(entry_id, dog_id, dog_id)
).fetchone()
if not exists:
raise HTTPException(404, "Eintrag nicht gefunden.")
# Felder updaten
fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items()
if v is not None}
if "tags" in fields: if "tags" in fields:
fields["tags"] = json.dumps(fields["tags"]) fields["tags"] = json.dumps(fields["tags"])
if not fields: if fields:
raise HTTPException(400, "Keine Änderungen.") # primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
set_clause = ", ".join(f"{k}=?" for k in fields) set_clause = ", ".join(f"{k}=?" for k in fields)
conn.execute( conn.execute(
f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?", f"UPDATE diary SET {set_clause} WHERE id=?",
list(fields.values()) + [entry_id, dog_id] list(fields.values()) + [entry_id]
) )
# Hunde-Zuweisung aktualisieren
if data.dog_ids is not None:
# primary dog des Eintrags ermitteln
primary = conn.execute(
"SELECT dog_id FROM diary WHERE id=?", (entry_id,)
).fetchone()["dog_id"]
all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn)
_set_dog_ids(conn, entry_id, all_dogs)
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
e = dict(row) dogs_map = _fetch_dog_ids(conn, [entry_id])
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
return e return _entry_dict(row, dogs_map)
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204) @router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
@ -142,7 +213,10 @@ async def upload_media(dog_id: int, entry_id: int,
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
entry = conn.execute( entry = conn.execute(
"SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) """SELECT d.id FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
(entry_id, dog_id, dog_id)
).fetchone() ).fetchone()
if not entry: if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")

View file

@ -113,13 +113,28 @@ async def upload_photo(
if not dog: if not dog:
raise HTTPException(404, "Hund nicht gefunden.") raise HTTPException(404, "Hund nicht gefunden.")
# Datei speichern # Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
ext = os.path.splitext(file.filename or "")[1] or ".jpg" import io
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}" from PIL import Image
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
content = await file.read()
try:
img = Image.open(io.BytesIO(content)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
content = buf.getvalue()
except Exception:
pass # Fallback: Originaldaten speichern
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}.jpg"
path = os.path.join(MEDIA_DIR, "dogs", filename) path = os.path.join(MEDIA_DIR, "dogs", filename)
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
content = await file.read()
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(content) f.write(content)

View file

@ -1,3 +1,283 @@
"""BAN YARO — health Routes (Stub, wird ausgebaut)""" """BAN YARO — Gesundheit & Impfpass Routes"""
from fastapi import APIRouter
import os, uuid
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter() router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# Erlaubte Typen
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument"}
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class HealthCreate(BaseModel):
typ: str
bezeichnung: str
datum: str
naechstes: Optional[str] = None
notiz: Optional[str] = None
# Gewicht
wert: Optional[float] = None
einheit: Optional[str] = "kg"
# Impfung
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
# Tierarztbesuch
kosten: Optional[float] = None
diagnose: Optional[str] = None
# Medikament
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
aktiv: Optional[int] = 1
bis_datum: Optional[str] = None
# Allergie
schweregrad: Optional[str] = None # leicht | mittel | schwer
reaktion: Optional[str] = None
erinnerung: Optional[int] = 1
class HealthUpdate(BaseModel):
bezeichnung: Optional[str] = None
datum: Optional[str] = None
naechstes: Optional[str] = None
notiz: Optional[str] = None
wert: Optional[float] = None
einheit: Optional[str] = None
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
kosten: Optional[float] = None
diagnose: Optional[str] = None
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
aktiv: Optional[int] = None
bis_datum: Optional[str] = None
schweregrad: Optional[str] = None
reaktion: Optional[str] = None
erinnerung: Optional[int] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Zugriffscheck Dog → User
# ------------------------------------------------------------------
def _check_dog_owner(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
# ------------------------------------------------------------------
# GET /api/dogs/{dog_id}/health
# ------------------------------------------------------------------
@router.get("/{dog_id}/health")
async def list_health(dog_id: int, typ: Optional[str] = None,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
if typ:
rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? AND typ=? ORDER BY datum DESC",
(dog_id, typ)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
(dog_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health
# ------------------------------------------------------------------
@router.post("/{dog_id}/health", status_code=201)
async def create_health(dog_id: int, data: HealthCreate,
user=Depends(get_current_user)):
if data.typ not in TYPEN:
raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(TYPEN)}")
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
conn.execute(
"""INSERT INTO health
(dog_id, typ, bezeichnung, datum, naechstes, notiz,
wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
dosierung, haeufigkeit, aktiv, bis_datum,
schweregrad, reaktion, erinnerung)
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)
)
row = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
(dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# PATCH /api/dogs/{dog_id}/health/{id}
# ------------------------------------------------------------------
@router.patch("/{dog_id}/health/{entry_id}")
async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute(
"SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
updates = {k: v for k, v in data.model_dump().items() if v is not None}
if not updates:
return dict(entry)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [entry_id]
conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/dogs/{dog_id}/health/{id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/health/{entry_id}", status_code=204)
async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute(
"SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
return None
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload
# ------------------------------------------------------------------
@router.post("/{dog_id}/health/{entry_id}/dokument")
async def upload_dokument(
dog_id: int,
entry_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute(
"SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
if ext not in {".jpg", ".jpeg", ".png", ".pdf", ".webp"}:
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "health", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
datei_url = f"/media/health/{filename}"
datei_typ = "pdf" if ext == ".pdf" else "image"
with db() as conn:
conn.execute(
"UPDATE health SET datei_url=?, datei_typ=? WHERE id=?",
(datei_url, datei_typ, entry_id)
)
return {"datei_url": datei_url, "datei_typ": datei_typ}
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------
class SymptomCheckRequest(BaseModel):
symptoms: str
@router.post("/{dog_id}/health/symptom-check")
async def symptom_check(dog_id: int, data: SymptomCheckRequest,
user=Depends(get_current_user)):
from ki import symptom_check as ki_symptom_check, KIUnavailableError
with db() as conn:
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
dog_info = dict(dog)
if dog_info.get("geburtstag"):
try:
from datetime import date
geb = date.fromisoformat(dog_info["geburtstag"])
dog_info["alter_jahre"] = round((date.today() - geb).days / 365.25, 1)
except Exception:
dog_info["alter_jahre"] = "unbekannt"
try:
result = await ki_symptom_check(
symptoms=data.symptoms,
dog_info=dog_info,
user_is_premium=bool(user.get("is_premium")),
)
return result
except KIUnavailableError as e:
raise HTTPException(503, str(e))
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/ki-zusammenfassung
# ------------------------------------------------------------------
@router.post("/{dog_id}/health/ki-zusammenfassung")
async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
from ki import health_summary, KIUnavailableError, KIPremiumRequired
with db() as conn:
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
(dog_id,)
).fetchall()
health_data = [dict(r) for r in rows]
try:
result = await health_summary(
health_data=health_data,
dog_info=dict(dog),
user_is_premium=bool(user.get("is_premium")),
)
return {"zusammenfassung": result}
except KIPremiumRequired as e:
raise HTTPException(402, str(e))
except KIUnavailableError as e:
raise HTTPException(503, str(e))

View file

@ -35,6 +35,10 @@ class PoisonCreate(BaseModel):
typ: str = "unbekannt" typ: str = "unbekannt"
class PoisonResolve(BaseModel):
grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig) # GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -114,7 +118,11 @@ async def confirm_poison(poison_id: int, user=Depends(get_current_user)):
# Nur der Melder selbst oder ein Admin # Nur der Melder selbst oder ein Admin
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.post("/{poison_id}/resolve") @router.post("/{poison_id}/resolve")
async def resolve_poison(poison_id: int, user=Depends(get_current_user)): async def resolve_poison(
poison_id: int,
data: PoisonResolve = PoisonResolve(),
user=Depends(get_current_user)
):
with db() as conn: with db() as conn:
entry = conn.execute( entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,) "SELECT * FROM poison WHERE id=?", (poison_id,)
@ -124,7 +132,16 @@ async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
e = dict(entry) e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin": if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.") raise HTTPException(403, "Keine Berechtigung.")
conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,)) # Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten
conn.execute(
"""UPDATE poison
SET geloest=1,
geloest_von=?,
geloest_at=datetime('now'),
geloest_grund=?
WHERE id=?""",
(user["id"], data.grund, poison_id)
)
return {"ok": True} return {"ok": True}

View file

@ -441,6 +441,7 @@ textarea.form-control {
padding: var(--space-4); padding: var(--space-4);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
animation: overlay-in var(--transition-normal) ease; animation: overlay-in var(--transition-normal) ease;
touch-action: manipulation;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.modal-overlay { align-items: center; } .modal-overlay { align-items: center; }
@ -812,3 +813,409 @@ textarea.form-control {
/* Leaflet-Attribution ausblenden */ /* Leaflet-Attribution ausblenden */
.leaflet-control-attribution { display: none !important; } .leaflet-control-attribution { display: none !important; }
/* ============================================================
GESUNDHEIT
============================================================ */
/* Header mit KI-Button */
.health-header {
display: flex;
justify-content: flex-end;
padding: var(--space-3) 0 var(--space-2);
}
/* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */
.health-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-1);
padding-bottom: var(--space-2);
margin-bottom: var(--space-3);
}
/* Auf sehr kleinen Screens: scrollen statt umbrechen */
@media (max-width: 480px) {
.health-tabs {
flex-wrap: nowrap;
overflow-x: auto;
padding-right: var(--space-4);
scrollbar-width: none;
}
.health-tabs::-webkit-scrollbar { display: none; }
}
.health-tab {
flex-shrink: 0;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--c-border);
border-radius: var(--radius-full);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
touch-action: manipulation;
}
.health-tab.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: var(--c-text-inverse);
}
/* Karten-Liste */
.health-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Einzelne Karte */
.health-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: var(--space-4);
cursor: pointer;
display: flex;
gap: var(--space-3);
align-items: flex-start;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.health-card:active { transform: scale(0.985); }
.health-card--inactive { opacity: 0.55; }
.health-card-body { flex: 1; min-width: 0; }
.health-card-title { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); }
.health-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); }
.health-card-next { font-size: var(--text-sm); font-weight: var(--weight-medium); margin-top: var(--space-1); }
.health-card-note { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); }
/* Ampel-Punkt (links an der Karte) */
.health-card-ampel {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.ampel-green { background: #22c55e; }
.ampel-yellow { background: #f59e0b; }
.ampel-red { background: #ef4444; }
.ampel-grey { background: var(--c-border); }
.ampel-text-green { color: #16a34a; }
.ampel-text-yellow { color: #d97706; }
.ampel-text-red { color: #dc2626; }
/* Gruppen-Label (z.B. "Aktuelle Medikamente") */
.health-group-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: var(--space-3) 0 var(--space-1);
}
/* Gewicht-Diagramm-Wrapper */
.health-chart-wrap {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
overflow: hidden;
}
/* Dokument-Thumbnail und Icon */
.health-doc-thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.health-doc-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: var(--c-surface-2);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
/* Detail-Dialog DL */
.health-detail-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-1) var(--space-4);
font-size: var(--text-sm);
}
.health-detail-dl dt {
color: var(--c-text-secondary);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.health-detail-dl dd { margin: 0; }
/* ------------------------------------------------------------
DOG SWITCHER
Avatar-Leiste in Header (Mobile) + Sidebar-Logo (Desktop)
------------------------------------------------------------ */
/* Aktiver (linker) Hund — primärer Ring */
.dog-sw-active {
width: 34px;
height: 34px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
flex-shrink: 0;
cursor: pointer;
border: 2.5px solid var(--c-primary);
transition: transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.dog-sw-active:hover {
transform: scale(1.06);
box-shadow: 0 0 0 3px var(--c-primary-subtle);
}
.dog-sw-active img { width:100%; height:100%; object-fit:cover; display:block; }
.dog-sw-active span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 17px;
}
/* "Ban Yaro" Label — füllt den Zwischenraum */
.dog-sw-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Gruppe der inaktiven Hunde (rechts) */
.dog-sw-others {
display: flex;
align-items: center;
position: relative; /* Anker für Quickpicker-Dropdown */
flex-shrink: 0;
}
/* Inaktiver Hund-Avatar */
.dog-sw-other {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
border: 2px solid var(--c-surface);
cursor: pointer;
flex-shrink: 0;
transition: transform var(--transition-fast);
position: relative;
}
.dog-sw-other:hover { transform: scale(1.12); }
.dog-sw-other img { width:100%; height:100%; object-fit:cover; display:block; }
.dog-sw-other span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 14px;
}
/* Gestapelter Avatar-Stack (3+ Hunde) */
.dog-sw-stack {
display: flex;
align-items: center;
cursor: pointer;
}
.dog-sw-stack .dog-sw-other { margin-left: -9px; }
.dog-sw-stack .dog-sw-other:first-child { margin-left: 0; }
.dog-sw-stack .dog-sw-other--0 { z-index: 3; }
.dog-sw-stack .dog-sw-other--1 { z-index: 2; }
.dog-sw-stack .dog-sw-other--2 { z-index: 1; }
.dog-sw-stack:hover .dog-sw-other { filter: brightness(1.05); }
/* +N Überlauf-Badge */
.dog-sw-more {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
background: var(--c-surface-2);
border: 2px solid var(--c-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: var(--weight-bold);
color: var(--c-text-secondary);
margin-left: -9px;
position: relative;
z-index: 0;
}
/* Quickpicker-Dropdown */
.dog-quickpick {
position: absolute;
top: calc(100% + 10px);
right: 0;
background: var(--c-surface);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: var(--space-2);
min-width: 170px;
z-index: 600;
}
.dog-quickpick.hidden { display: none; }
.dog-qp-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text);
transition: background var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.dog-qp-item:hover { background: var(--c-bg); }
.dog-qp-av {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
flex-shrink: 0;
}
.dog-qp-av img { width:100%; height:100%; object-fit:cover; display:block; }
.dog-qp-av span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 16px;
}
/* Sidebar: größerer aktiver Avatar */
#sidebar-dog-switcher .dog-sw-active {
width: 36px;
height: 36px;
}
/* ------------------------------------------------------------
DIARY Multi-Dog (Hunde-Auswahl im Formular + Karten-Anzeige)
------------------------------------------------------------ */
/* Avatar-Reihe in der Tagebuch-Karte */
.diary-dog-row {
display: flex;
align-items: center;
gap: -4px; /* überlappend via margin */
margin-top: var(--space-2);
flex-wrap: wrap;
gap: var(--space-1);
}
.diary-dog-av {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
overflow: hidden;
background: var(--c-surface-2);
border: 1.5px solid var(--c-surface);
flex-shrink: 0;
}
.diary-dog-av img { width:100%; height:100%; object-fit:cover; display:block; }
.diary-dog-av span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 12px;
}
/* Hunde-Chip in der Detail-Ansicht */
.diary-detail-dogs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.diary-dog-chip {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px 3px 4px;
background: var(--c-surface-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--c-text-secondary);
}
.diary-dog-chip .diary-dog-av {
width: 20px;
height: 20px;
}
/* Hunde-Picker im Formular */
.diary-dog-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.diary-dog-pick-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--c-border-light);
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text-secondary);
transition: border-color var(--transition-fast),
background var(--transition-fast),
color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.diary-dog-pick-item input { display: none; }
.diary-dog-pick-item .diary-dog-av {
width: 26px;
height: 26px;
}
.diary-dog-pick-item:hover {
border-color: var(--c-primary);
color: var(--c-text);
}
.diary-dog-pick-item.checked {
border-color: var(--c-primary);
background: var(--c-primary-subtle);
color: var(--c-primary-dark);
font-weight: var(--weight-semibold);
}

View file

@ -58,6 +58,16 @@
flex: 1; flex: 1;
} }
/* Dog Switcher Container im Header */
#header-dog-switcher {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
overflow: visible;
}
.header-back { .header-back {
display: flex; display: flex;
align-items: center; align-items: center;
@ -201,7 +211,7 @@
background: var(--c-surface); background: var(--c-surface);
border-right: 1px solid var(--c-border-light); border-right: 1px solid var(--c-border-light);
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow: hidden; /* Sidebar selbst scrollt nicht */
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@ -229,13 +239,30 @@
color: var(--c-text); color: var(--c-text);
} }
.sidebar-add {
padding: var(--space-4) var(--space-4) var(--space-2);
flex-shrink: 0;
}
.sidebar-nav { .sidebar-nav {
flex: 1; flex: 1;
padding: var(--space-4) var(--space-2); padding: var(--space-2) var(--space-2) var(--space-4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1); gap: var(--space-1);
overflow-y: auto;
min-height: 0; /* wichtig: flex-child darf kleiner werden als Inhalt */
/* Firefox */
scrollbar-width: thin;
scrollbar-color: var(--c-primary) var(--c-surface);
} }
.sidebar-nav::-webkit-scrollbar { width: 6px; }
.sidebar-nav::-webkit-scrollbar-track { background: var(--c-surface); }
.sidebar-nav::-webkit-scrollbar-thumb {
background: var(--c-primary);
border-radius: 3px;
}
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: var(--c-primary-dark); }
.sidebar-section-label { .sidebar-section-label {
font-size: var(--text-xs); font-size: var(--text-xs);
@ -270,6 +297,12 @@
color: var(--c-primary-dark); color: var(--c-primary-dark);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
} }
/* User-Eintrag bekommt nie den Active-Stil */
.sidebar-item--user.active {
background: transparent;
color: var(--c-text-secondary);
font-weight: var(--weight-medium);
}
.sidebar-item-icon { .sidebar-item-icon {
font-size: 18px; font-size: 18px;
width: 24px; width: 24px;
@ -291,11 +324,7 @@
padding: 0 var(--space-1); padding: 0 var(--space-1);
} }
.sidebar-footer { /* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */
padding: var(--space-4) var(--space-2);
border-top: 1px solid var(--c-border-light);
flex-shrink: 0;
}
/* ------------------------------------------------------------ /* ------------------------------------------------------------
5. PAGE WRAPPER (inneres Layout der Seiten) 5. PAGE WRAPPER (inneres Layout der Seiten)

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

View file

@ -6,9 +6,14 @@
<meta name="theme-color" content="#C4843A"> <meta name="theme-color" content="#C4843A">
<meta name="description" content="Ban Yaro — Die Hunde-Plattform. Alles rund um deinen Hund."> <meta name="description" content="Ban Yaro — Die Hunde-Plattform. Alles rund um deinen Hund.">
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png">
<!-- PWA --> <!-- PWA -->
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon-180.png"> <link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ban Yaro"> <meta name="apple-mobile-web-app-title" content="Ban Yaro">
@ -30,17 +35,23 @@
<!-- MOBILE HEADER (wird per JS mit Seitentitel befüllt) --> <!-- MOBILE HEADER (wird per JS mit Seitentitel befüllt) -->
<header id="app-header"> <header id="app-header">
<button class="header-back hidden" id="header-back" aria-label="Zurück">&#8592;</button> <button class="header-back hidden" id="header-back" aria-label="Zurück">&#8592;</button>
<div id="header-dog-switcher" class="dog-switcher">
<span class="header-title" id="header-title">Ban Yaro</span> <span class="header-title" id="header-title">Ban Yaro</span>
</div>
<div id="header-actions"></div> <div id="header-actions"></div>
</header> </header>
<!-- DESKTOP SIDEBAR --> <!-- DESKTOP SIDEBAR -->
<nav id="sidebar" role="navigation" aria-label="Hauptnavigation"> <nav id="sidebar" role="navigation" aria-label="Hauptnavigation">
<div class="sidebar-logo"> <div class="sidebar-logo" id="sidebar-dog-switcher">
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro"> <img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span> <span class="sidebar-logo-text">Ban Yaro</span>
</div> </div>
<div class="sidebar-add">
<button class="btn btn-primary btn-full" id="sidebar-add">+ Neu erstellen</button>
</div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<span class="sidebar-section-label">Mein Hund</span> <span class="sidebar-section-label">Mein Hund</span>
<div class="sidebar-item active" data-page="diary"> <div class="sidebar-item active" data-page="diary">
@ -92,13 +103,8 @@
<div class="sidebar-item" data-page="movies"> <div class="sidebar-item" data-page="movies">
<span class="sidebar-item-icon">🎬</span> Filme <span class="sidebar-item-icon">🎬</span> Filme
</div> </div>
</div>
<div class="sidebar-footer"> <div class="sidebar-item sidebar-item--user" id="sidebar-user">
<div class="sidebar-item" data-page="settings">
<span class="sidebar-item-icon">⚙️</span> Einstellungen
</div>
<div class="sidebar-item" id="sidebar-user">
<span class="sidebar-item-icon">👤</span> <span class="sidebar-item-icon">👤</span>
<span id="sidebar-username">Anmelden</span> <span id="sidebar-username">Anmelden</span>
</div> </div>
@ -179,9 +185,9 @@
<span class="nav-item-icon">📖</span> <span class="nav-item-icon">📖</span>
<span class="nav-item-label">Tagebuch</span> <span class="nav-item-label">Tagebuch</span>
</div> </div>
<div class="nav-item" data-page="map"> <div class="nav-item" data-page="health">
<span class="nav-item-icon">🗺️</span> <span class="nav-item-icon">💉</span>
<span class="nav-item-label">Karte</span> <span class="nav-item-label">Gesundheit</span>
</div> </div>
<!-- Mittlerer + Button --> <!-- Mittlerer + Button -->
<div class="nav-item nav-item-center" id="nav-add"> <div class="nav-item nav-item-center" id="nav-add">

View file

@ -116,10 +116,19 @@ const API = (() => {
// GESUNDHEIT // GESUNDHEIT
// ---------------------------------------------------------- // ----------------------------------------------------------
const health = { const health = {
list(dogId) { return get(`/dogs/${dogId}/health`); }, list(dogId, typ = null) {
const q = typ ? `?typ=${encodeURIComponent(typ)}` : '';
return get(`/dogs/${dogId}/health${q}`);
},
create(dogId, data) { return post(`/dogs/${dogId}/health`, data); }, create(dogId, data) { return post(`/dogs/${dogId}/health`, data); },
update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); }, update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); },
delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); }, delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); },
uploadDokument(dogId, id, formData) {
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
},
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
symptomCheck(dogId, symptoms) { symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms }); return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
}, },
@ -134,7 +143,7 @@ const API = (() => {
}, },
report(data) { return post('/poison', data); }, report(data) { return post('/poison', data); },
confirm(id) { return post(`/poison/${id}/confirm`); }, confirm(id) { return post(`/poison/${id}/confirm`); },
resolve(id) { return post(`/poison/${id}/resolve`); }, resolve(id, data={}) { return post(`/poison/${id}/resolve`, data); },
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); }, uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
}; };

View file

@ -56,8 +56,9 @@ const App = (() => {
document.querySelectorAll(`[data-page="${pageId}"]`) document.querySelectorAll(`[data-page="${pageId}"]`)
.forEach(el => el.classList.add('active')); .forEach(el => el.classList.add('active'));
// Header-Titel setzen // Header-Titel setzen (nur wenn kein Dog-Switcher aktiv ist)
document.getElementById('header-title').textContent = pages[pageId].title; const titleEl = document.getElementById('header-title');
if (titleEl) titleEl.textContent = pages[pageId].title;
// History // History
if (pushHistory) { if (pushHistory) {
@ -79,8 +80,12 @@ const App = (() => {
return; return;
} }
// Guard: verhindert doppelten Load bei gleichzeitigen navigate()-Aufrufen
if (page._loading) return;
page._loading = true;
const container = document.querySelector(`#page-${pageId} .page-body`); const container = document.querySelector(`#page-${pageId} .page-body`);
if (!container) return; if (!container) { page._loading = false; return; }
// Skeleton während Laden // Skeleton während Laden
container.innerHTML = UI.skeleton(4); container.innerHTML = UI.skeleton(4);
@ -108,6 +113,8 @@ const App = (() => {
text: 'Diese Seite ist noch in Entwicklung.', text: 'Diese Seite ist noch in Entwicklung.',
}); });
page.module = {}; page.module = {};
} finally {
page._loading = false;
} }
} }
@ -134,8 +141,14 @@ const App = (() => {
return; return;
} }
// + Button // Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
if (e.target.closest('#nav-add')) { if (e.target.closest('#sidebar-user')) {
navigate('settings');
return;
}
// + Button (Mobile Bottom-Nav + Desktop Sidebar)
if (e.target.closest('#nav-add') || e.target.closest('#sidebar-add')) {
_showQuickAdd(); _showQuickAdd();
} }
}); });
@ -146,11 +159,8 @@ const App = (() => {
navigate(page, false); navigate(page, false);
}); });
// Initial: URL-Hash auslesen // Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier),
const hash = location.hash.replace('#', ''); // damit kein doppelter _loadPage()-Aufruf entsteht.
if (hash && pages[hash]) {
navigate(hash, false);
}
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -184,13 +194,18 @@ const App = (() => {
document.querySelector('#modal-container').addEventListener('click', e => { document.querySelector('#modal-container').addEventListener('click', e => {
const btn = e.target.closest('[data-quick]'); const btn = e.target.closest('[data-quick]');
if (!btn) return; if (!btn) return;
UI.modal.close();
const action = btn.dataset.quick; const action = btn.dataset.quick;
UI.modal.close();
// Kurzes Delay wegen iOS Ghost-Click: nach modal.close() feuert iOS
// ~300ms später ein synthetisches Click-Event an derselben Position.
// Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort.
setTimeout(() => {
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); } if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); } if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); } if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
}, 350);
}, { once: true }); }, { once: true });
} }
@ -201,19 +216,22 @@ const App = (() => {
try { try {
const user = await API.auth.me(); const user = await API.auth.me();
state.user = user; state.user = user;
_onLoggedIn(); await _onLoggedIn();
} catch { } catch {
_onLoggedOut(); _onLoggedOut();
} }
} }
function _onLoggedIn() { async function _onLoggedIn() {
document.getElementById('sidebar-username').textContent = state.user.name; document.getElementById('sidebar-username').textContent = state.user.name;
_loadDogs(); await _loadDogs();
} }
function _onLoggedOut() { function _onLoggedOut() {
state.user = null; state.user = null;
state.dogs = [];
state.activeDog = null;
_renderDogSwitcher();
navigate('settings', false); navigate('settings', false);
} }
@ -221,18 +239,142 @@ const App = (() => {
try { try {
state.dogs = await API.dogs.list(); state.dogs = await API.dogs.list();
if (state.dogs.length > 0) { if (state.dogs.length > 0) {
state.activeDog = state.dogs[0]; // Zuletzt aktiven Hund aus localStorage wiederherstellen
const savedId = parseInt(localStorage.getItem('by_active_dog') || '0');
state.activeDog = state.dogs.find(d => d.id === savedId) || state.dogs[0];
} }
// Seitenmodule über neuen Hund informieren _renderDogSwitcher();
_notifyDogChange(); _notifyDogChange();
} catch { /* kein Hund vorhanden */ } } catch { /* kein Hund vorhanden */ }
} }
function _notifyDogChange() { function _notifyDogChange() {
// Alle geladenen Seiten-Module über Hundwechsel informieren
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog)); Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
} }
// ----------------------------------------------------------
// HUNDE-SWITCHER (Header Mobile + Sidebar-Logo Desktop)
// ----------------------------------------------------------
function _renderDogSwitcher() {
_renderSwitcherInto(document.getElementById('header-dog-switcher'), 'hdr');
_renderSwitcherInto(document.getElementById('sidebar-dog-switcher'), 'sb');
}
function _renderSwitcherInto(el, ctxId) {
if (!el) return;
const dog = state.activeDog;
const others = state.dogs.filter(d => d.id !== dog?.id);
// Fallback: kein User oder kein Hund → Standardlogo
if (!state.user || !dog) {
if (ctxId === 'sb') {
el.innerHTML = `
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>`;
} else {
el.innerHTML = `<span class="header-title" id="header-title">Ban Yaro</span>`;
}
return;
}
const avHtml = d => d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}">`
: `<span>🐕</span>`;
// Inaktive Hunde rechts
let othersHtml = '';
if (others.length === 1) {
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-other" data-dog-id="${others[0].id}" title="${_esc(others[0].name)}">
${avHtml(others[0])}
</div>
</div>`;
} else if (others.length >= 2) {
const visible = others.slice(0, 3);
const extraCount = others.length - 3;
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-stack" id="dog-sw-stack-${ctxId}">
${visible.map((d, i) => `
<div class="dog-sw-other dog-sw-other--${i}" data-dog-id="${d.id}" title="${_esc(d.name)}">
${avHtml(d)}
</div>`).join('')}
${extraCount > 0 ? `<div class="dog-sw-more">+${extraCount}</div>` : ''}
</div>
<div class="dog-quickpick hidden" id="dog-qp-${ctxId}">
${others.map(d => `
<div class="dog-qp-item" data-dog-id="${d.id}">
<div class="dog-qp-av">${avHtml(d)}</div>
<span>${_esc(d.name)}</span>
</div>`).join('')}
</div>
</div>`;
}
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
el.innerHTML = `
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${_esc(dog.name)} bearbeiten">
${avHtml(dog)}
</div>
<span class="${titleClass} dog-sw-title">Ban Yaro</span>
${othersHtml}`;
// Klick aktiver Avatar → Hund-Profil
el.querySelector(`#dog-sw-active-${ctxId}`)?.addEventListener('click', () => {
navigate('dog-profile');
});
// 1 anderer Hund → direkter Tausch
if (others.length === 1) {
el.querySelector('.dog-sw-other')?.addEventListener('click', () => {
setActiveDog(others[0].id);
});
}
// 2+ andere Hunde → Stack klick öffnet Quickpicker
if (others.length >= 2) {
const stack = el.querySelector(`#dog-sw-stack-${ctxId}`);
const qp = el.querySelector(`#dog-qp-${ctxId}`);
stack?.addEventListener('click', e => {
e.stopPropagation();
// Alle anderen Quickpicker schließen
document.querySelectorAll('.dog-quickpick').forEach(q => {
if (q !== qp) q.classList.add('hidden');
});
qp?.classList.toggle('hidden');
});
el.querySelectorAll('.dog-qp-item').forEach(item => {
item.addEventListener('click', e => {
e.stopPropagation();
setActiveDog(parseInt(item.dataset.dogId));
qp?.classList.add('hidden');
});
});
}
}
// Quickpicker schließen bei Klick außerhalb
document.addEventListener('click', () => {
document.querySelectorAll('.dog-quickpick').forEach(q => q.classList.add('hidden'));
});
function setActiveDog(dogId) {
const dog = state.dogs.find(d => d.id === dogId);
if (!dog || dog.id === state.activeDog?.id) return;
state.activeDog = dog;
localStorage.setItem('by_active_dog', String(dogId));
_renderDogSwitcher();
_notifyDogChange();
}
function _esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// INITIALISIERUNG // INITIALISIERUNG
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -240,16 +382,19 @@ const App = (() => {
_bindNavigation(); _bindNavigation();
await _checkAuth(); await _checkAuth();
// Erste Seite laden (Hash oder Standard: diary) // Erste Seite laden: Hash aus URL oder Standard 'diary'.
const startPage = location.hash.replace('#', '') || 'diary'; // Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird
navigate(pages[startPage] ? startPage : 'diary', false); // (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf).
const hash = location.hash.replace('#', '');
const startPage = (hash && pages[hash]) ? hash : 'diary';
navigate(startPage, false);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// ÖFFENTLICHE API // ÖFFENTLICHE API
// (andere Module können App.state, App.navigate etc. nutzen) // (andere Module können App.state, App.navigate etc. nutzen)
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, navigate, state }; return { init, navigate, state, renderDogSwitcher: _renderDogSwitcher };
})(); })();

View file

@ -38,6 +38,11 @@ window.Page_diary = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
async function refresh() { async function refresh() {
if (!_appState.activeDog) return; if (!_appState.activeDog) return;
// Wenn vorher "kein Hund"-Zustand: #diary-list existiert nicht → voll neu rendern
if (!_container.querySelector('#diary-list')) {
await _render();
return;
}
_offset = 0; _offset = 0;
_entries = []; _entries = [];
await _load(); await _load();
@ -185,6 +190,9 @@ window.Page_diary = (() => {
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>` ? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: ''; : '';
// Mehrere Hunde: kleine Avatare in der Karte
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
return ` return `
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}"> <div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo} ${photo}
@ -196,11 +204,24 @@ window.Page_diary = (() => {
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''} ${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
${textPreview} ${textPreview}
${tagsHtml} ${tagsHtml}
${dogAvatars}
</div> </div>
</div> </div>
`; `;
} }
function _dogAvatarRow(dogIds) {
if (!dogIds || dogIds.length <= 1) return '';
const avatars = dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// DETAIL-ANSICHT // DETAIL-ANSICHT
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -217,6 +238,22 @@ window.Page_diary = (() => {
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">` style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: ''; : '';
// Hunde-Anzeige wenn mehrere beteiligt
const dogIds = entry.dog_ids || [entry.dog_id];
const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs">
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
</div>
<span>${_escape(dog.name)}</span>
</div>` : '';
}).join('')}
</div>`
: '';
const body = ` const body = `
${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''} ${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''}
${photo} ${photo}
@ -226,6 +263,7 @@ window.Page_diary = (() => {
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span> </span>
</div> </div>
${dogsHtml}
${entry.text ${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>` ? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>`
: ''} : ''}
@ -265,11 +303,31 @@ window.Page_diary = (() => {
function _showForm(entry) { function _showForm(entry) {
const isEdit = !!entry; const isEdit = !!entry;
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const activeDog = _appState.activeDog;
const typOpts = Object.entries(TYPEN) const typOpts = Object.entries(TYPEN)
.map(([val, { icon, label }]) => .map(([val, { icon, label }]) =>
`<option value="${val}" ${entry?.typ === val ? 'selected' : ''}>${icon} ${label}</option>`) `<option value="${val}" ${entry?.typ === val ? 'selected' : ''}>${icon} ${label}</option>`)
.join(''); .join('');
// Weitere Hunde: alle außer dem aktiven
const otherDogs = _appState.dogs.filter(d => d.id !== activeDog?.id);
const entryDogIds = entry?.dog_ids || [activeDog?.id];
const dogPickerHtml = otherDogs.length > 0 ? `
<div class="form-group">
<label class="form-label">Betrifft auch</label>
<div class="diary-dog-picker">
${otherDogs.map(d => `
<label class="diary-dog-pick-item ${entryDogIds.includes(d.id) ? 'checked' : ''}">
<input type="checkbox" name="extra_dog" value="${d.id}"
${entryDogIds.includes(d.id) ? 'checked' : ''}>
<div class="diary-dog-av">
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : '<span>🐕</span>'}
</div>
<span>${_escape(d.name)}</span>
</label>`).join('')}
</div>
</div>` : '';
const body = ` const body = `
<form id="diary-form" autocomplete="off"> <form id="diary-form" autocomplete="off">
<div class="form-group"> <div class="form-group">
@ -291,6 +349,7 @@ window.Page_diary = (() => {
<textarea class="form-control" name="text" rows="5" <textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea> placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
</div> </div>
${dogPickerHtml}
<div class="form-group"> <div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer"> <label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_milestone" ${entry?.is_milestone ? 'checked' : ''}> <input type="checkbox" name="is_milestone" ${entry?.is_milestone ? 'checked' : ''}>
@ -318,6 +377,9 @@ window.Page_diary = (() => {
const form = document.getElementById('diary-form'); const form = document.getElementById('diary-form');
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// Foto-Vorschau // Foto-Vorschau
const photoInput = form.querySelector('[name="photo"]'); const photoInput = form.querySelector('[name="photo"]');
const photoPreview = document.getElementById('diary-photo-preview'); const photoPreview = document.getElementById('diary-photo-preview');
@ -330,11 +392,25 @@ window.Page_diary = (() => {
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Checked-Klasse auf Dog-Picker-Items toggeln
form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.diary-dog-pick-item').classList.toggle('checked', cb.checked);
});
});
form.addEventListener('submit', async e => { form.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const submitBtn = form.querySelector('[type="submit"]'); const submitBtn = form.querySelector('[type="submit"]');
const fd = UI.formData(form); const fd = UI.formData(form);
// dog_ids zusammenbauen: aktiver Hund + gewählte weitere
const dogIds = [_appState.activeDog.id];
form.querySelectorAll('.diary-dog-pick-item input:checked').forEach(cb => {
const id = parseInt(cb.value);
if (!dogIds.includes(id)) dogIds.push(id);
});
await UI.asyncButton(submitBtn, async () => { await UI.asyncButton(submitBtn, async () => {
const payload = { const payload = {
datum: fd.datum || null, datum: fd.datum || null,
@ -342,6 +418,7 @@ window.Page_diary = (() => {
titel: fd.titel || null, titel: fd.titel || null,
text: fd.text || null, text: fd.text || null,
is_milestone: 'is_milestone' in fd, is_milestone: 'is_milestone' in fd,
dog_ids: dogIds,
}; };
if (isEdit) { if (isEdit) {

View file

@ -14,6 +14,22 @@ window.Page_dog_profile = (() => {
async function init(container, appState) { async function init(container, appState) {
_container = container; _container = container;
_appState = appState; _appState = appState;
// Event-Delegation auf dem persistenten Container — überlebt innerHTML-Ersatz
_container.addEventListener('click', e => {
if (e.target.closest('#dp-add-dog-btn')) {
_openCreateModal();
return;
}
if (e.target.closest('#dp-edit-btn')) {
if (_appState.activeDog) _openEditModal(_appState.activeDog);
return;
}
if (e.target.closest('#profile-goto-login')) {
App.navigate('settings');
}
});
await _render(); await _render();
} }
@ -77,7 +93,7 @@ window.Page_dog_profile = (() => {
title="Foto ändern"> title="Foto ändern">
📷 📷
<input type="file" id="dp-photo-input" accept="image/*" <input type="file" id="dp-photo-input" accept="image/*"
capture="user" style="display:none"> style="display:none">
</label> </label>
</div> </div>
@ -135,9 +151,14 @@ window.Page_dog_profile = (() => {
</div> </div>
` : ''} ` : ''}
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary w-full" id="dp-edit-btn"> <button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten Profil bearbeiten
</button> </button>
<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>
</div>
</div> </div>
`; `;
@ -150,7 +171,6 @@ window.Page_dog_profile = (() => {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const result = await API.dogs.uploadPhoto(dog.id, fd); const result = await API.dogs.uploadPhoto(dog.id, fd);
// State in-place aktualisieren
dog.foto_url = result.foto_url; dog.foto_url = result.foto_url;
_appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url }; _appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url };
_appState.dogs = _appState.dogs.map(d => _appState.dogs = _appState.dogs.map(d =>
@ -162,11 +182,7 @@ window.Page_dog_profile = (() => {
UI.toast.error(err.message || 'Fehler beim Hochladen.'); UI.toast.error(err.message || 'Fehler beim Hochladen.');
} }
}); });
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
// Bearbeiten öffnen
document.getElementById('dp-edit-btn')?.addEventListener('click', () => {
_openEditModal(dog);
});
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -190,18 +206,26 @@ window.Page_dog_profile = (() => {
_bindForm(null, false); _bindForm(null, false);
} }
// ----------------------------------------------------------
// NEUEN HUND ANLEGEN (Modal) — auch aufrufbar via addNew()
// ----------------------------------------------------------
function _openCreateModal() {
UI.modal.open({ title: 'Weiteren Hund anlegen', body: _formHTML(null, true) });
_bindForm(null, true);
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// BEARBEITEN (Modal) // BEARBEITEN (Modal)
// ---------------------------------------------------------- // ----------------------------------------------------------
function _openEditModal(dog) { function _openEditModal(dog) {
UI.modal.open({ title: `${dog.name} bearbeiten`, body: _formHTML(dog) }); UI.modal.open({ title: `${dog.name} bearbeiten`, body: _formHTML(dog, true) });
_bindForm(dog, true); _bindForm(dog, true);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// FORMULAR HTML // FORMULAR HTML
// ---------------------------------------------------------- // ----------------------------------------------------------
function _formHTML(dog) { function _formHTML(dog, inModal = false) {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
return ` return `
<form id="dp-form" autocomplete="off"> <form id="dp-form" autocomplete="off">
@ -270,8 +294,24 @@ window.Page_dog_profile = (() => {
</label> </label>
</div> </div>
<div class="form-group">
<label class="form-label">Foto</label>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<img id="dp-form-preview"
src="${dog?.foto_url || ''}"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
background:var(--c-surface-2);border:2px solid var(--c-border);
display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
📷 Foto auswählen
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)"> <div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
${dog ? `<button type="button" class="btn btn-secondary flex-1" ${(dog || inModal) ? `<button type="button" class="btn btn-secondary flex-1"
id="dp-form-cancel">Abbrechen</button>` : ''} id="dp-form-cancel">Abbrechen</button>` : ''}
<button type="submit" class="btn btn-primary flex-1"> <button type="submit" class="btn btn-primary flex-1">
${dog ? 'Speichern' : '🐕 Hund anlegen'} ${dog ? 'Speichern' : '🐕 Hund anlegen'}
@ -299,6 +339,22 @@ window.Page_dog_profile = (() => {
const form = document.getElementById('dp-form'); const form = document.getElementById('dp-form');
if (!form) return; if (!form) return;
// Foto-Vorschau
const fotoInput = document.getElementById('dp-form-foto');
const fotoPreview = document.getElementById('dp-form-preview');
if (fotoInput && fotoPreview) {
fotoInput.addEventListener('change', () => {
const file = fotoInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
fotoPreview.src = e.target.result;
fotoPreview.style.display = 'block';
};
reader.readAsDataURL(file);
});
}
document.getElementById('dp-form-cancel') document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close); ?.addEventListener('click', UI.modal.close);
@ -355,8 +411,29 @@ window.Page_dog_profile = (() => {
saved = await API.dogs.create(payload); saved = await API.dogs.create(payload);
_appState.dogs.push(saved); _appState.dogs.push(saved);
_appState.activeDog = saved; _appState.activeDog = saved;
localStorage.setItem('by_active_dog', String(saved.id));
if (inModal) UI.modal.close();
UI.toast.success(`${saved.name} wurde angelegt! 🎉`); UI.toast.success(`${saved.name} wurde angelegt! 🎉`);
} }
// Foto hochladen wenn gewählt
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
if (fotoFile) {
try {
const fd = new FormData();
fd.append('file', fotoFile);
const result = await API.dogs.uploadPhoto(saved.id, fd);
saved.foto_url = result.foto_url;
_appState.activeDog = { ...saved };
_appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d);
} catch {
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
}
}
// Dog Switcher in Header + Sidebar aktualisieren
App.renderDogSwitcher?.();
await _render(); await _render();
}); });
}); });
@ -390,6 +467,6 @@ window.Page_dog_profile = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, refresh, onDogChange }; return { init, refresh, onDogChange, addNew: _openCreateModal };
})(); })();

View file

@ -0,0 +1,727 @@
/* ============================================================
BAN YARO Gesundheit & Impfpass (Sprint 3)
Tabs: Impfungen | Tierarzt | Gewicht | Medikamente | Allergien | Dokumente
+ KI-Gesundheitszusammenfassung
============================================================ */
window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {}; // { impfung:[], tierarzt:[], gewicht:[], medikament:[], allergie:[], dokument:[] }
let _activeTab = 'impfung';
const TABS = [
{ key: 'impfung', label: 'Impfpass', 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: '📄' },
];
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
async function refresh() {
if (!_appState.activeDog) return;
if (!_container.querySelector('#health-tabs')) {
await _render();
return;
}
await _loadAll();
_renderTab();
}
async function onDogChange() {
_data = {};
await _render();
}
function openNew() {
_showForm(null, _activeTab);
}
// ----------------------------------------------------------
// RENDER — Hauptstruktur
// ----------------------------------------------------------
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '💉',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}
_container.innerHTML = `
<div class="health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
KI-Zusammenfassung
</button>
</div>
<div class="health-tabs" id="health-tabs"></div>
<div id="health-tab-content"></div>
`;
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
await _loadAll();
_renderTab();
}
function _renderTabBar() {
const tabsEl = _container.querySelector('#health-tabs');
tabsEl.innerHTML = TABS.map(t => `
<button class="health-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>
`).join('');
tabsEl.querySelectorAll('.health-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
tabsEl.querySelectorAll('.health-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderTab();
});
});
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadAll() {
const dogId = _appState.activeDog.id;
try {
const all = await API.health.list(dogId);
_data = {};
TABS.forEach(t => { _data[t.key] = []; });
all.forEach(e => {
if (_data[e.typ]) _data[e.typ].push(e);
});
} catch (err) {
UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.');
}
}
// ----------------------------------------------------------
// TAB-INHALT RENDERN
// ----------------------------------------------------------
function _renderTab() {
const content = _container.querySelector('#health-tab-content');
if (!content) return;
const entries = _data[_activeTab] || [];
switch (_activeTab) {
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
}
_bindTabEvents(content);
}
// ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status
// ----------------------------------------------------------
function _renderImpfungen(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💉', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
});
const items = entries.map(e => {
const ampel = _impfAmpel(e.naechstes);
return `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-ampel ampel-${ampel.color}" title="${ampel.label}"></div>
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${e.tierarzt_name ? ` · ${_esc(e.tierarzt_name)}` : ''}
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
</div>
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`;
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
function _impfAmpel(naechstesStr) {
if (!naechstesStr) return { color: 'grey', label: 'Kein Folgedatum', icon: '' };
const diff = (new Date(naechstesStr) - Date.now()) / 86400000; // Tage
if (diff < 0) return { color: 'red', label: 'Überfällig!', icon: '🔴' };
if (diff < 60) return { color: 'yellow', label: 'Bald fällig', icon: '🟡' };
return { color: 'green', label: 'Aktuell', icon: '🟢' };
}
// ----------------------------------------------------------
// TIERARZTBESUCHE
// ----------------------------------------------------------
function _renderTierarzt(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🏥', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
});
const items = entries.map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${UI.time.format(e.datum + 'T00:00:00')}
${e.tierarzt_name ? ` · ${_esc(e.tierarzt_name)}` : ''}
${e.kosten != null ? ` · ${Number(e.kosten).toFixed(2)}` : ''}
</div>
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// GEWICHT — mit SVG-Diagramm
// ----------------------------------------------------------
function _renderGewicht(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
if (!entries.length) return UI.emptyState({
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
});
const items = sorted.slice().reverse().map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title" style="font-size:var(--text-xl);font-weight:700">
${e.wert} ${e.einheit || 'kg'}
</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('');
return `
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''}
<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
}
function _weightChart(entries) {
const W = 320, H = 120, PAD = 24;
const vals = entries.map(e => parseFloat(e.wert));
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const pts = entries.map((e, i) => {
const x = PAD + (i / (entries.length - 1)) * (W - PAD * 2);
const y = PAD + (1 - (parseFloat(e.wert) - min) / range) * (H - PAD * 2);
return `${x},${y}`;
});
const dots = entries.map((e, i) => {
const [x, y] = pts[i].split(',');
return `<circle cx="${x}" cy="${y}" r="4" fill="var(--c-primary)"/>`;
}).join('');
const labels = [
`<text x="${PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)">${entries[0].datum.slice(5)}</text>`,
`<text x="${W - PAD}" y="${H - 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${entries[entries.length - 1].datum.slice(5)}</text>`,
`<text x="${PAD - 2}" y="${PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${max.toFixed(1)}</text>`,
`<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="10" fill="var(--c-text-secondary)" text-anchor="end">${min.toFixed(1)}</text>`,
].join('');
return `
<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:${W}px;display:block;margin:0 auto">
<polyline points="${pts.join(' ')}"
fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round"/>
${dots}
${labels}
</svg>
`;
}
// ----------------------------------------------------------
// MEDIKAMENTE
// ----------------------------------------------------------
function _renderMedikamente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Medikament eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💊', title: 'Noch keine Medikamente', action: addBtn
});
const aktive = entries.filter(e => e.aktiv);
const inaktive = entries.filter(e => !e.aktiv);
const renderGroup = (items, label) => items.length ? `
<div class="health-group-label">${label}</div>
${items.map(e => `
<div class="health-card${e.aktiv ? '' : ' health-card--inactive'}" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">
${e.dosierung ? _esc(e.dosierung) : ''}
${e.haeufigkeit ? ` · ${_esc(e.haeufigkeit)}` : ''}
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('')}
` : '';
return `
<div class="health-list">
${renderGroup(aktive, '💊 Aktuelle Medikamente')}
${renderGroup(inaktive, 'Vergangene Medikamente')}
</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`;
}
// ----------------------------------------------------------
// ALLERGIEN
// ----------------------------------------------------------
function _renderAllergien(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Allergie eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🌿', title: 'Noch keine Allergien eingetragen', action: addBtn
});
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
const items = entries.map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
<div class="health-card-title">
${e.schweregrad ? SCHWEREGRAD[e.schweregrad] || '' : ''} ${_esc(e.bezeichnung)}
</div>
<div class="health-card-meta">
Erstmals: ${UI.time.format(e.datum + 'T00:00:00')}
${e.schweregrad ? ` · Schweregrad: ${_esc(e.schweregrad)}` : ''}
</div>
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
`).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// DOKUMENTE
// ----------------------------------------------------------
function _renderDokumente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
if (!entries.length) return UI.emptyState({
icon: '📄', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
});
const items = entries.map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body" style="display:flex;gap:var(--space-3);align-items:center">
${e.datei_url
? (e.datei_typ === 'pdf'
? `<div class="health-doc-icon">📄</div>`
: `<img src="${e.datei_url}" class="health-doc-thumb" alt="Dokument">`)
: `<div class="health-doc-icon">📎</div>`}
<div>
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>
</div>
`).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// EVENTS BINDEN
// ----------------------------------------------------------
function _bindTabEvents(content) {
content.querySelectorAll('[data-action="add-entry"]').forEach(btn => {
btn.addEventListener('click', () => _showForm(null, _activeTab));
});
content.querySelectorAll('[data-action="open-entry"]').forEach(card => {
const id = parseInt(card.dataset.id);
const entry = (_data[_activeTab] || []).find(e => e.id === id);
if (entry) card.addEventListener('click', () => _openDetail(entry));
});
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// ----------------------------------------------------------
function _openDetail(entry) {
const tabInfo = TABS.find(t => t.key === entry.typ) || TABS[0];
const fields = _detailFields(entry);
const body = `
<div class="health-detail">
${fields}
${entry.datei_url
? (entry.datei_typ === 'pdf'
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)">📄 PDF öffnen</a>`
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''}
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)">
<button class="btn btn-secondary flex-1" id="health-detail-edit">Bearbeiten</button>
<button class="btn btn-danger flex-1" id="health-detail-delete">Löschen</button>
</div>
`;
UI.modal.open({ title: `${tabInfo.icon} ${_esc(entry.bezeichnung)}`, body });
document.getElementById('health-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_showForm(entry, entry.typ);
});
document.getElementById('health-detail-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Eintrag löschen?',
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
confirmText: 'Löschen',
danger: true,
});
if (ok) {
try {
await API.health.delete(_appState.activeDog.id, entry.id);
_data[entry.typ] = (_data[entry.typ] || []).filter(e => e.id !== entry.id);
UI.modal.close();
_renderTab();
UI.toast.success('Eintrag gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
}
});
}
function _detailFields(e) {
const rows = [];
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
if (e.naechstes) rows.push(['Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
if (e.tierarzt_name) rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
if (e.charge_nr) rows.push(['Charge-Nr.', _esc(e.charge_nr)]);
if (e.kosten != null) rows.push(['Kosten', `${Number(e.kosten).toFixed(2)}`]);
if (e.diagnose) rows.push(['Diagnose', _esc(e.diagnose)]);
if (e.wert) rows.push(['Gewicht', `${e.wert} ${e.einheit || 'kg'}`]);
if (e.dosierung) rows.push(['Dosierung', _esc(e.dosierung)]);
if (e.haeufigkeit) rows.push(['Häufigkeit', _esc(e.haeufigkeit)]);
if (e.bis_datum) rows.push(['Bis', UI.time.format(e.bis_datum + 'T00:00:00')]);
if (e.schweregrad) rows.push(['Schweregrad',_esc(e.schweregrad)]);
if (e.reaktion) rows.push(['Reaktion', _esc(e.reaktion)]);
if (e.notiz) rows.push(['Notiz', _esc(e.notiz)]);
return `<dl class="health-detail-dl">${
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
}</dl>`;
}
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry, typ) {
const isEdit = !!entry;
const today = new Date().toISOString().slice(0, 10);
const t = typ || _activeTab;
const commonFields = `
<div class="form-group">
<label class="form-label">Bezeichnung *</label>
<input class="form-control" type="text" name="bezeichnung"
value="${_esc(entry?.bezeichnung || '')}" required
placeholder="${_formPlaceholder(t)}">
</div>
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
`;
const extraFields = _extraFormFields(entry, t);
const notizField = `
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="3">${_esc(entry?.notiz || '')}</textarea>
</div>
`;
const uploadField = t === 'dokument' ? `
<div class="form-group">
<label class="form-label">Datei (JPG, PNG, PDF)</label>
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
</div>
` : '';
const body = `
<form id="health-form" autocomplete="off">
${commonFields}
${extraFields}
${notizField}
${uploadField}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Erstellen'}</button>
</div>
</form>
`;
const tabInfo = TABS.find(tab => tab.key === t) || TABS[0];
UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body });
const form = document.getElementById('health-form');
setTimeout(() => form?.querySelector('[name="bezeichnung"]')?.focus(), 150);
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
form.addEventListener('submit', async e => {
e.preventDefault();
const btn = form.querySelector('[type="submit"]');
const fd = UI.formData(form);
await UI.asyncButton(btn, async () => {
const payload = _buildPayload(fd, t);
let saved;
if (isEdit) {
saved = await API.health.update(_appState.activeDog.id, entry.id, payload);
const idx = (_data[t] || []).findIndex(x => x.id === entry.id);
if (idx !== -1) _data[t][idx] = saved;
UI.toast.success('Gespeichert.');
} else {
saved = await API.health.create(_appState.activeDog.id, { ...payload, typ: t });
if (!_data[t]) _data[t] = [];
_data[t].unshift(saved);
UI.toast.success('Eintrag erstellt.');
}
// Datei-Upload für Dokumente
if (t === 'dokument' && form.querySelector('[name="datei"]')?.files[0]) {
try {
const formData = new FormData();
formData.append('file', form.querySelector('[name="datei"]').files[0]);
const res = await API.health.uploadDokument(_appState.activeDog.id, saved.id, formData);
saved.datei_url = res.datei_url;
saved.datei_typ = res.datei_typ;
} catch {
UI.toast.warning('Eintrag erstellt, Datei konnte nicht hochgeladen werden.');
}
}
UI.modal.close();
_renderTab();
});
});
}
function _formPlaceholder(typ) {
const ph = {
impfung: 'z.B. Tollwut, DHPP, Leptospirose',
tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung',
gewicht: '',
medikament: 'z.B. Frontline, Milbemax',
allergie: 'z.B. Hühnchen, Gras, Hausstaub',
dokument: 'z.B. Impfpass, Blutbild',
};
return ph[typ] || '';
}
function _extraFormFields(entry, typ) {
switch (typ) {
case 'impfung': return `
<div class="form-group">
<label class="form-label">Nächste Impfung (optional)</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input class="form-control" type="text" name="tierarzt_name" value="${_esc(entry?.tierarzt_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Chargen-Nr.</label>
<input class="form-control" type="text" name="charge_nr" value="${_esc(entry?.charge_nr || '')}">
</div>
`;
case 'entwurmung': return `
<div class="form-group">
<label class="form-label">Nächste Behandlung (optional)</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input class="form-control" type="text" name="tierarzt_name" value="${_esc(entry?.tierarzt_name || '')}">
</div>
`;
case 'tierarzt': return `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<input class="form-control" type="text" name="tierarzt_name" value="${_esc(entry?.tierarzt_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Diagnose</label>
<textarea class="form-control" name="diagnose" rows="2">${_esc(entry?.diagnose || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Kosten ()</label>
<input class="form-control" type="number" step="0.01" min="0" name="kosten"
value="${entry?.kosten ?? ''}">
</div>
<div class="form-group">
<label class="form-label">Nächster Termin (optional)</label>
<input class="form-control" type="date" name="naechstes" value="${entry?.naechstes || ''}">
</div>
`;
case 'gewicht': return `
<div class="form-group">
<label class="form-label">Gewicht (kg) *</label>
<input class="form-control" type="number" step="0.1" min="0" name="wert"
value="${entry?.wert ?? ''}" required>
</div>
`;
case 'medikament': return `
<div class="form-group">
<label class="form-label">Dosierung</label>
<input class="form-control" type="text" name="dosierung"
value="${_esc(entry?.dosierung || '')}" placeholder="z.B. 1 Tablette">
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
<input class="form-control" type="text" name="haeufigkeit"
value="${_esc(entry?.haeufigkeit || '')}" placeholder="z.B. täglich, 2x wöchentlich">
</div>
<div class="form-group">
<label class="form-label">Gabe bis (optional)</label>
<input class="form-control" type="date" name="bis_datum" value="${entry?.bis_datum || ''}">
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="aktiv" ${entry?.aktiv !== 0 ? 'checked' : ''}>
Aktuell aktiv
</label>
</div>
`;
case 'allergie': return `
<div class="form-group">
<label class="form-label">Schweregrad</label>
<select class="form-control" name="schweregrad">
<option value=""> unbekannt </option>
${['leicht', 'mittel', 'schwer'].map(s =>
`<option value="${s}" ${entry?.schweregrad === s ? 'selected' : ''}>${s}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Reaktion / Symptome</label>
<textarea class="form-control" name="reaktion" rows="2">${_esc(entry?.reaktion || '')}</textarea>
</div>
`;
default: return '';
}
}
function _buildPayload(fd, typ) {
const p = {
bezeichnung: fd.bezeichnung || null,
datum: fd.datum || null,
notiz: fd.notiz || null,
naechstes: fd.naechstes || null,
tierarzt_name: fd.tierarzt_name || null,
charge_nr: fd.charge_nr || null,
diagnose: fd.diagnose || null,
dosierung: fd.dosierung || null,
haeufigkeit: fd.haeufigkeit || null,
bis_datum: fd.bis_datum || null,
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 (typ === 'medikament') {
p.aktiv = 'aktiv' in fd ? 1 : 0;
}
// Gewicht-Einheit
p.einheit = fd.einheit || 'kg';
return p;
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
UI.setLoading(btn, true);
try {
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
UI.modal.open({
title: '✨ KI-Gesundheitsbericht',
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
});
} catch (err) {
if (err.status === 503) {
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
} else if (err.status === 402) {
UI.toast.warning('Diese Funktion ist Teil von Ban Yaro Premium.');
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Zusammenfassung.');
}
} finally {
UI.setLoading(btn, false);
}
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh, openNew, onDogChange };
})();

View file

@ -392,24 +392,53 @@ window.Page_poison = (() => {
} }
}); });
document.getElementById('detail-resolve')?.addEventListener('click', async () => { document.getElementById('detail-resolve')?.addEventListener('click', () => {
const ok = await UI.modal.confirm({ _showResolveDialog(r);
title : 'Meldung als erledigt markieren?',
message: 'Das Problem wurde beseitigt oder die Meldung war fehlerhaft.',
confirmText: 'Erledigt markieren',
}); });
if (!ok) return; }
try {
await API.poison.resolve(r.id); // ----------------------------------------------------------
// ERLEDIGT-DIALOG — mit Grundauswahl für KI-Analyse
// ----------------------------------------------------------
function _showResolveDialog(r) {
UI.modal.open({
title: '✔ Meldung als erledigt markieren',
body: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Die Meldung wird inaktiv gesetzt. Die Daten bleiben für spätere
Musteranalysen gespeichert.
</p>
<div class="form-group">
<label class="form-label">Grund</label>
<select class="form-control" id="resolve-grund">
<option value="beseitigt"> Gefahr wurde beseitigt</option>
<option value="fehlerhaft"> Meldung war fehlerhaft</option>
<option value="anderes">💬 Anderer Grund</option>
</select>
</div>
`,
footer: `
<button class="btn btn-secondary" id="resolve-cancel">Abbrechen</button>
<button class="btn btn-nature" id="resolve-confirm">Erledigt markieren</button>
`,
});
document.getElementById('resolve-cancel')
?.addEventListener('click', UI.modal.close);
document.getElementById('resolve-confirm')
?.addEventListener('click', async () => {
const grund = document.getElementById('resolve-grund')?.value || 'beseitigt';
const btn = document.getElementById('resolve-confirm');
await UI.asyncButton(btn, async () => {
await API.poison.resolve(r.id, { grund });
_reports = _reports.filter(x => x.id !== r.id); _reports = _reports.filter(x => x.id !== r.id);
_markers.splice(_reports.length, 1); // cleanup (wird bei _renderMarkers neu gesetzt)
_renderMarkers(); _renderMarkers();
_renderList(); _renderList();
_updateBadge(_reports.length); _updateBadge(_reports.length);
UI.modal.close();
UI.toast.success('Meldung als erledigt markiert.'); UI.toast.success('Meldung als erledigt markiert.');
} catch (err) { });
UI.toast.error(err.message || 'Fehler.');
}
}); });
} }

View file

@ -101,10 +101,10 @@ const UI = (() => {
onClose: () => resolve(false), onClose: () => resolve(false),
}); });
m.parentElement.querySelector('#modal-cancel').addEventListener('click', () => { m.parentElement.querySelector('#modal-cancel').addEventListener('click', () => {
close(); resolve(false); resolve(false); close();
}); });
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => { m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
close(); resolve(true); resolve(true); close();
}); });
}); });
} }

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications Offline-Cache + Push Notifications
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v1'; const CACHE_VERSION = 'by-v3';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
// Diese Dateien werden beim Install gecacht (App Shell) // Diese Dateien werden beim Install gecacht (App Shell)

View file

@ -1,7 +1,7 @@
services: services:
banyaro: banyaro:
build: . build: .
container_name: ban-yaro container_name: banyaro
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter