Compare commits
No commits in common. "d8b9561fffe0d0ed0cffd09f1a7f0dc1c0158dbb" and "5a0be0e8865167795ba81f51e86858c62241646c" have entirely different histories.
d8b9561fff
...
5a0be0e886
28 changed files with 146 additions and 2173 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,6 +7,3 @@ __pycache__/
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
# Design-Quell-Dateien (nicht für Server)
|
|
||||||
/icons/
|
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -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/banyaro
|
DS_PATH := /volume1/docker/ban-yaro
|
||||||
CONTAINER := banyaro # container_name (für docker logs/exec)
|
CONTAINER := ban-yaro # 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
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,6 @@ 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,
|
||||||
|
|
@ -194,15 +186,6 @@ 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,
|
||||||
|
|
@ -251,48 +234,4 @@ 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.")
|
|
||||||
|
|
|
||||||
|
|
@ -289,71 +289,6 @@ 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)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,6 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,12 @@ 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):
|
||||||
|
|
@ -41,63 +38,23 @@ 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 DISTINCT d.* FROM diary d
|
"""SELECT * FROM diary WHERE dog_id=?
|
||||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
ORDER BY datum DESC, created_at DESC
|
||||||
WHERE d.dog_id = ? OR dd.dog_id = ?
|
|
||||||
ORDER BY d.datum DESC, d.created_at DESC
|
|
||||||
LIMIT ? OFFSET ?""",
|
LIMIT ? OFFSET ?""",
|
||||||
(dog_id, dog_id, limit, offset)
|
(dog_id, limit, offset)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
ids = [r["id"] for r in rows]
|
entries = []
|
||||||
dogs_map = _fetch_dog_ids(conn, ids)
|
for r in rows:
|
||||||
|
e = dict(r)
|
||||||
return [_entry_dict(r, dogs_map) for r in rows]
|
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{dog_id}/diary", status_code=201)
|
@router.post("/{dog_id}/diary", status_code=201)
|
||||||
|
|
@ -115,8 +72,6 @@ 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)
|
||||||
|
|
@ -130,10 +85,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"]])
|
|
||||||
|
|
||||||
return _entry_dict(entry, dogs_map)
|
e = dict(entry)
|
||||||
|
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}")
|
||||||
|
|
@ -141,16 +96,13 @@ 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 DISTINCT d.* FROM diary d
|
"SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
||||||
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.")
|
||||||
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
e = dict(row)
|
||||||
|
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
||||||
return _entry_dict(row, dogs_map)
|
return e
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{dog_id}/diary/{entry_id}")
|
@router.patch("/{dog_id}/diary/{entry_id}")
|
||||||
|
|
@ -158,43 +110,20 @@ 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 fields:
|
if not fields:
|
||||||
# primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
|
raise HTTPException(400, "Keine Änderungen.")
|
||||||
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=?",
|
f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?",
|
||||||
list(fields.values()) + [entry_id]
|
list(fields.values()) + [entry_id, dog_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()
|
||||||
dogs_map = _fetch_dog_ids(conn, [entry_id])
|
e = dict(row)
|
||||||
|
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
|
||||||
return _entry_dict(row, dogs_map)
|
return e
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
|
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
|
||||||
|
|
@ -213,10 +142,7 @@ 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 d.id FROM diary d
|
"SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
||||||
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.")
|
||||||
|
|
|
||||||
|
|
@ -113,28 +113,13 @@ async def upload_photo(
|
||||||
if not dog:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
|
# Datei speichern
|
||||||
import io
|
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||||
from PIL import Image
|
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,283 +1,3 @@
|
||||||
"""BAN YARO — Gesundheit & Impfpass Routes"""
|
"""BAN YARO — health Routes (Stub, wird ausgebaut)"""
|
||||||
|
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))
|
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,6 @@ 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)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -118,11 +114,7 @@ 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(
|
async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
|
||||||
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,)
|
||||||
|
|
@ -132,16 +124,7 @@ async def resolve_poison(
|
||||||
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.")
|
||||||
# Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten
|
conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,))
|
||||||
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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,6 @@ 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; }
|
||||||
|
|
@ -813,409 +812,3 @@ 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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -58,16 +58,6 @@
|
||||||
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;
|
||||||
|
|
@ -211,7 +201,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: hidden; /* Sidebar selbst scrollt nicht */
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,30 +229,13 @@
|
||||||
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-2) var(--space-2) var(--space-4);
|
padding: var(--space-4) var(--space-2);
|
||||||
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);
|
||||||
|
|
@ -297,12 +270,6 @@
|
||||||
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;
|
||||||
|
|
@ -324,7 +291,11 @@
|
||||||
padding: 0 var(--space-1);
|
padding: 0 var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */
|
.sidebar-footer {
|
||||||
|
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.
|
Before Width: | Height: | Size: 972 B |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 320 KiB |
|
|
@ -6,14 +6,9 @@
|
||||||
<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" sizes="180x180" href="/icons/icon-180.png">
|
<link rel="apple-touch-icon" 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">
|
||||||
|
|
@ -35,23 +30,17 @@
|
||||||
<!-- 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">←</button>
|
<button class="header-back hidden" id="header-back" aria-label="Zurück">←</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" id="sidebar-dog-switcher">
|
<div class="sidebar-logo">
|
||||||
<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">
|
||||||
|
|
@ -103,8 +92,13 @@
|
||||||
<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-item sidebar-item--user" id="sidebar-user">
|
<div class="sidebar-footer">
|
||||||
|
<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>
|
||||||
|
|
@ -185,9 +179,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="health">
|
<div class="nav-item" data-page="map">
|
||||||
<span class="nav-item-icon">💉</span>
|
<span class="nav-item-icon">🗺️</span>
|
||||||
<span class="nav-item-label">Gesundheit</span>
|
<span class="nav-item-label">Karte</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">
|
||||||
|
|
|
||||||
|
|
@ -116,19 +116,10 @@ const API = (() => {
|
||||||
// GESUNDHEIT
|
// GESUNDHEIT
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const health = {
|
const health = {
|
||||||
list(dogId, typ = null) {
|
list(dogId) { return get(`/dogs/${dogId}/health`); },
|
||||||
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 });
|
||||||
},
|
},
|
||||||
|
|
@ -143,7 +134,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, data={}) { return post(`/poison/${id}/resolve`, data); },
|
resolve(id) { return post(`/poison/${id}/resolve`); },
|
||||||
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
|
uploadPhoto(id, form){ return upload(`/poison/${id}/photo`, form); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,8 @@ 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 (nur wenn kein Dog-Switcher aktiv ist)
|
// Header-Titel setzen
|
||||||
const titleEl = document.getElementById('header-title');
|
document.getElementById('header-title').textContent = pages[pageId].title;
|
||||||
if (titleEl) titleEl.textContent = pages[pageId].title;
|
|
||||||
|
|
||||||
// History
|
// History
|
||||||
if (pushHistory) {
|
if (pushHistory) {
|
||||||
|
|
@ -80,12 +79,8 @@ 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) { page._loading = false; return; }
|
if (!container) return;
|
||||||
|
|
||||||
// Skeleton während Laden
|
// Skeleton während Laden
|
||||||
container.innerHTML = UI.skeleton(4);
|
container.innerHTML = UI.skeleton(4);
|
||||||
|
|
@ -113,8 +108,6 @@ const App = (() => {
|
||||||
text: 'Diese Seite ist noch in Entwicklung.',
|
text: 'Diese Seite ist noch in Entwicklung.',
|
||||||
});
|
});
|
||||||
page.module = {};
|
page.module = {};
|
||||||
} finally {
|
|
||||||
page._loading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,14 +134,8 @@ const App = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
|
// + Button
|
||||||
if (e.target.closest('#sidebar-user')) {
|
if (e.target.closest('#nav-add')) {
|
||||||
navigate('settings');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// + Button (Mobile Bottom-Nav + Desktop Sidebar)
|
|
||||||
if (e.target.closest('#nav-add') || e.target.closest('#sidebar-add')) {
|
|
||||||
_showQuickAdd();
|
_showQuickAdd();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -159,8 +146,11 @@ const App = (() => {
|
||||||
navigate(page, false);
|
navigate(page, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier),
|
// Initial: URL-Hash auslesen
|
||||||
// damit kein doppelter _loadPage()-Aufruf entsteht.
|
const hash = location.hash.replace('#', '');
|
||||||
|
if (hash && pages[hash]) {
|
||||||
|
navigate(hash, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -194,18 +184,13 @@ 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;
|
||||||
const action = btn.dataset.quick;
|
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
// Kurzes Delay wegen iOS Ghost-Click: nach modal.close() feuert iOS
|
const action = btn.dataset.quick;
|
||||||
// ~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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,22 +201,19 @@ const App = (() => {
|
||||||
try {
|
try {
|
||||||
const user = await API.auth.me();
|
const user = await API.auth.me();
|
||||||
state.user = user;
|
state.user = user;
|
||||||
await _onLoggedIn();
|
_onLoggedIn();
|
||||||
} catch {
|
} catch {
|
||||||
_onLoggedOut();
|
_onLoggedOut();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _onLoggedIn() {
|
function _onLoggedIn() {
|
||||||
document.getElementById('sidebar-username').textContent = state.user.name;
|
document.getElementById('sidebar-username').textContent = state.user.name;
|
||||||
await _loadDogs();
|
_loadDogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _onLoggedOut() {
|
function _onLoggedOut() {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.dogs = [];
|
|
||||||
state.activeDog = null;
|
|
||||||
_renderDogSwitcher();
|
|
||||||
navigate('settings', false);
|
navigate('settings', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,142 +221,18 @@ 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) {
|
||||||
// Zuletzt aktiven Hund aus localStorage wiederherstellen
|
state.activeDog = state.dogs[0];
|
||||||
const savedId = parseInt(localStorage.getItem('by_active_dog') || '0');
|
|
||||||
state.activeDog = state.dogs.find(d => d.id === savedId) || state.dogs[0];
|
|
||||||
}
|
}
|
||||||
_renderDogSwitcher();
|
// Seitenmodule über neuen Hund informieren
|
||||||
_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, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// INITIALISIERUNG
|
// INITIALISIERUNG
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -382,19 +240,16 @@ const App = (() => {
|
||||||
_bindNavigation();
|
_bindNavigation();
|
||||||
await _checkAuth();
|
await _checkAuth();
|
||||||
|
|
||||||
// Erste Seite laden: Hash aus URL oder Standard 'diary'.
|
// Erste Seite laden (Hash oder Standard: diary)
|
||||||
// Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird
|
const startPage = location.hash.replace('#', '') || 'diary';
|
||||||
// (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf).
|
navigate(pages[startPage] ? startPage : 'diary', false);
|
||||||
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, renderDogSwitcher: _renderDogSwitcher };
|
return { init, navigate, state };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,6 @@ 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();
|
||||||
|
|
@ -190,9 +185,6 @@ 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}
|
||||||
|
|
@ -204,24 +196,11 @@ 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
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -238,22 +217,6 @@ 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}
|
||||||
|
|
@ -263,7 +226,6 @@ 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>`
|
||||||
: ''}
|
: ''}
|
||||||
|
|
@ -303,31 +265,11 @@ 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">
|
||||||
|
|
@ -349,7 +291,6 @@ 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' : ''}>
|
||||||
|
|
@ -377,9 +318,6 @@ 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');
|
||||||
|
|
@ -392,25 +330,11 @@ 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,
|
||||||
|
|
@ -418,7 +342,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +77,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/*"
|
||||||
style="display:none">
|
capture="user" style="display:none">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -151,14 +135,9 @@ 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>
|
||||||
`;
|
`;
|
||||||
|
|
@ -171,6 +150,7 @@ 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 =>
|
||||||
|
|
@ -182,7 +162,11 @@ 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -206,26 +190,18 @@ 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, true) });
|
UI.modal.open({ title: `${dog.name} bearbeiten`, body: _formHTML(dog) });
|
||||||
_bindForm(dog, true);
|
_bindForm(dog, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// FORMULAR HTML
|
// FORMULAR HTML
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _formHTML(dog, inModal = false) {
|
function _formHTML(dog) {
|
||||||
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">
|
||||||
|
|
@ -294,24 +270,8 @@ 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 || inModal) ? `<button type="button" class="btn btn-secondary flex-1"
|
${dog ? `<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'}
|
||||||
|
|
@ -339,22 +299,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -411,29 +355,8 @@ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -467,6 +390,6 @@ window.Page_dog_profile = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
return { init, refresh, onDogChange, addNew: _openCreateModal };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,727 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, refresh, openNew, onDogChange };
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -392,53 +392,24 @@ window.Page_poison = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('detail-resolve')?.addEventListener('click', () => {
|
document.getElementById('detail-resolve')?.addEventListener('click', async () => {
|
||||||
_showResolveDialog(r);
|
const ok = await UI.modal.confirm({
|
||||||
|
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.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
resolve(false); close();
|
close(); resolve(false);
|
||||||
});
|
});
|
||||||
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
|
m.parentElement.querySelector('#modal-confirm').addEventListener('click', () => {
|
||||||
resolve(true); close();
|
close(); resolve(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications
|
Offline-Cache + Push Notifications
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v3';
|
const CACHE_VERSION = 'by-v1';
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
services:
|
services:
|
||||||
banyaro:
|
banyaro:
|
||||||
build: .
|
build: .
|
||||||
container_name: banyaro
|
container_name: ban-yaro
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue